diff --git a/package-lock.json b/package-lock.json index ee3cc80..1421f3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@xterm/xterm": "^6.0.0", "driver.js": "^1.4.0", "i18next": "^26.4.2", + "lowlight": "^3.3.0", "lucide-react": "^1.14.0", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", @@ -5249,6 +5250,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "11.11.2", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz", + "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -6178,6 +6188,21 @@ "node": ">=8" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", diff --git a/package.json b/package.json index 32a2909..a22f430 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@xterm/xterm": "^6.0.0", "driver.js": "^1.4.0", "i18next": "^26.4.2", + "lowlight": "^3.3.0", "lucide-react": "^1.14.0", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", diff --git a/scripts/generate-nanumsquare-css.py b/scripts/generate-nanumsquare-css.py new file mode 100644 index 0000000..cfdcdfa --- /dev/null +++ b/scripts/generate-nanumsquare-css.py @@ -0,0 +1,85 @@ +"""Regenerate font coverage after replacing the bundled NanumSquare WOFF2 files. + +Requires fonttools[woff]. Normal app builds use the checked-in CSS, not Python. +Run: python3 scripts/generate-nanumsquare-css.py [--check] +""" + +import argparse +import hashlib +import unicodedata +from pathlib import Path + +from fontTools.pens.boundsPen import BoundsPen +from fontTools.ttLib import TTFont + + +ROOT = Path(__file__).resolve().parents[1] +FONTS = ROOT / "src/renderer/public/assets/fonts" +OUTPUT = ROOT / "src/renderer/src/styles/nanumSquare.css" +FACES = [(300, "NanumSquareL"), (400, "NanumSquare"), (700, "NanumSquareB"), (800, "NanumSquareEB")] +# Hangul fillers are intentionally invisible, like whitespace and controls. +FILLERS = {0x115F, 0x1160, 0x3164, 0xFFA0} + + +def coverage(font): + glyphs = font.getGlyphSet() + supported = [] + for code, name in sorted(font.getBestCmap().items()): + pen = BoundsPen(glyphs) + glyphs[name].draw(pen) + intentional = chr(code).isspace() or unicodedata.category(chr(code)) in {"Cc", "Cf"} or code in FILLERS + if pen.bounds is not None or intentional: + supported.append(code) + return supported + + +def unicode_ranges(codes): + ranges = [] + start = end = codes[0] + for code in codes[1:]: + if code == end + 1: + end = code + else: + ranges.append((start, end)) + start = end = code + ranges.append((start, end)) + return [f"U+{start:X}" if start == end else f"U+{start:X}-{end:X}" for start, end in ranges] + + +def generate(): + sections = [ + "/* Generated by scripts/generate-nanumsquare-css.py. Do not edit manually.\n" + " * Empty glyphs are excluded so the existing system-font fallback can render them.\n" + " * Font binaries and visible glyphs are unchanged.\n */" + ] + for weight, name in FACES: + file = FONTS / f"{name}.woff2" + with TTFont(file) as font: + ranges = unicode_ranges(coverage(font)) + lines = [", ".join(ranges[index:index + 10]) for index in range(0, len(ranges), 10)] + sections.append( + f"/* {file.name} sha256: {hashlib.sha256(file.read_bytes()).hexdigest()} */\n" + "@font-face {\n" + " font-family: 'NanumSquare';\n" + f" src: url('/assets/fonts/{file.name}') format('woff2');\n" + f" font-weight: {weight};\n" + " font-style: normal;\n" + " font-display: swap;\n" + " unicode-range:\n " + ",\n ".join(lines) + ";\n}" + ) + return "\n\n".join(sections) + "\n" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + generated = generate() + if args.check: + if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != generated: + raise SystemExit("NanumSquare coverage CSS is stale; regenerate it") + print("NanumSquare coverage matches all four font files") + else: + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(generated, encoding="utf-8") + print(f"Generated {OUTPUT.relative_to(ROOT)}") diff --git a/scripts/test-agent-code-navigation.cjs b/scripts/test-agent-code-navigation.cjs new file mode 100644 index 0000000..7aefa14 --- /dev/null +++ b/scripts/test-agent-code-navigation.cjs @@ -0,0 +1,172 @@ +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 { createHash, webcrypto } = require('node:crypto') + +function compile(source, globals = {}, requireModule = require) { + const exports = {} + vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { + exports, require: requireModule, Error, crypto: webcrypto, TextEncoder, window: { setTimeout: callback => setTimeout(callback, 1) }, ...globals + }) + return exports +} +const tools = compile(fs.readFileSync(require.resolve('../src/shared/agentTools.ts'), 'utf8')) +const navigation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentCodeNavigation.ts'), 'utf8'), {}, name => name.endsWith('shared/agentTools') ? tools : require(name)) +const content = 'def configure():\n speed = 0.4\n return speed\n' +const hash = value => createHash('sha256').update(value).digest('hex') +const reference = { id: 'location', moduleName: 'Control', path: 'src/node.py', startLine: 2, endLine: 3, reason: 'speed config', snippet: ' speed = 0.4\n return speed', expectedHash: hash(content) } + +test('unchanged source reveals the verified line range, while unsaved insertions relocate it', async () => { + assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content, reference))), { startLine: 2, endLine: 3 }) + assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange('# unsaved\n' + content, reference))), { startLine: 3, endLine: 4 }) + assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content.replace(/\n/g, '\r\n'), reference))), { startLine: 2, endLine: 3 }) +}) + +test('rejects missing, ambiguous or altered code rather than highlighting a guessed line', async () => { + for (const changed of [content.replace('0.4', '0.8'), `${content}\n${content}`]) await assert.rejects(navigation.resolveCodeReferenceRange(changed, reference), /위치를 확정/) + for (const patch of [{ expectedHash: undefined }, { snippet: '' }, { snippet: '[REDACTED]' }, { snippet: 'one line' }, { path: '../etc/passwd' }, { path: '.env' }, { startLine: 0 }, { endLine: 500 }]) { + await assert.rejects(navigation.resolveCodeReferenceRange(content, { ...reference, ...patch })) + } +}) + +function editorHarness(text = content) { + const calls = [] + const collection = { clear: () => calls.push('clear') } + const model = { uri: { path: '/modules/Control/src/node.py' }, getValue: () => text, isDisposed: () => false, getLineMaxColumn: line => text.split('\n')[line - 1].length + 1 } + const editor = { getModel: () => model, getDomNode: () => ({ clientWidth: 800, clientHeight: 600 }), layout: () => calls.push('layout'), createDecorationsCollection: value => { calls.push(['decorate', value]); return collection }, revealRangeInCenter: value => calls.push(['reveal', value]), setPosition: value => calls.push(['position', value]), focus: () => calls.push('focus') } + const options = { file: 'modules/Control/src/node.py', reference, isCurrent: () => true, getEditor: () => editor, getActiveFile: () => 'modules/Control/src/node.py', isReady: () => true, attempts: 4 } + return { model, editor, options, calls, collection } +} + +test('focuses and highlights only after the correct Monaco model has loaded', async () => { + const h = editorHarness() + let reads = 0 + h.options.getEditor = () => ++reads < 3 ? null : h.editor + assert.equal(await navigation.revealAgentCodeReference(h.options), h.collection) + assert.deepEqual(h.calls.map(call => Array.isArray(call) ? call[0] : call), ['layout', 'decorate', 'position', 'reveal', 'focus']) + const selection = h.calls.find(call => call[0] === 'reveal')[1] + assert.equal(selection.startLineNumber, 2) + assert.equal(selection.endLineNumber, 3) + assert.equal(h.calls[1][1][0].options.isWholeLine, true) +}) + +test('does not decorate another file, a hidden/diff editor, stale scope or unready model', async () => { + for (const setup of [ + h => { h.model.uri.path = '/modules/Other/src/node.py' }, + h => { h.options.getActiveFile = () => 'other.py' }, + h => { h.options.isReady = () => false }, + h => { h.options.isCurrent = () => false }, + h => { h.editor.getDomNode = () => ({ clientWidth: 0, clientHeight: 0 }) }, + h => { h.model.isDisposed = () => true } + ]) { + const h = editorHarness() + setup(h) + await assert.rejects(navigation.revealAgentCodeReference(h.options)) + assert.equal(h.calls.length, 0) + } +}) + +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) && ['openCodeReference', 'cancelCodeReference'].includes(node.declarationList.declarations[0].name.getText(hookSource))) +function consentHarness() { + const session = {} + const messageStateRef = { current: { session, messages: [{ id: 'answer', role: 'assistant', plan: { codeReferences: [reference] } }] } } + const globals = { session, messageStateRef, navigatingRef: { current: false }, applyingRef: { current: false }, abortControllerRef: { current: null }, updateMessages: update => { messageStateRef.current.messages = update(messageStateRef.current.messages) } } + return { ...compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), globals), globals, message: () => messageStateRef.current.messages[0] } +} + +test('opening requires explicit approval, cancellation never opens or applies anything', async () => { + const h = consentHarness() + let opened = 0 + const open = async () => { opened++ } + assert.equal(opened, 0) + h.cancelCodeReference('answer', 'location') + await h.openCodeReference('answer', 'location', open) + assert.equal(opened, 0) + assert.equal(h.message().codeReferenceStates.location.status, 'cancelled') + const approved = consentHarness() + await approved.openCodeReference('answer', 'location', async ref => { assert.equal(ref.path, reference.path); opened++ }) + assert.equal(opened, 1) + assert.equal(approved.message().codeReferenceStates.location.status, 'opened') +}) + +test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => { + const h = consentHarness() + let finish, opened = 0 + const first = h.openCodeReference('answer', 'location', () => { opened++; return new Promise(resolve => { finish = resolve }) }) + assert.equal(h.message().codeReferenceStates.location.status, 'opening') + h.cancelCodeReference('answer', 'location') + assert.equal(h.message().codeReferenceStates.location.status, 'opening') + await h.openCodeReference('answer', 'location', async () => { opened++ }) + finish() + await first + assert.equal(opened, 1) + await h.openCodeReference('answer', 'invented', async () => { opened++ }) + h.globals.applyingRef.current = true + await h.openCodeReference('answer', 'location', async () => { opened++ }) + assert.equal(opened, 1) +}) + +test('errors remain visible and late navigation cannot update another conversation', async () => { + const h = consentHarness() + await h.openCodeReference('answer', 'location', async () => { throw Error('missing file') }) + assert.equal(h.message().codeReferenceStates.location.status, 'error') + assert.equal(h.message().codeReferenceStates.location.error, 'missing file') + let finish + const pending = h.openCodeReference('answer', 'location', () => new Promise(resolve => { finish = resolve })) + const other = { id: 'other' } + h.globals.messageStateRef.current = { session: {}, messages: [other] } + finish() + await pending + assert.equal(h.message(), other) +}) + +test('a new question cancels pending navigation without leaving its old state stuck opening', async () => { + const h = consentHarness() + let finish, current + const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => { + current = isCurrent + return new Promise((_resolve, reject) => { finish = () => reject(Error('cancelled')) }) + }) + h.globals.messageStateRef.current.messages.push({ id: 'new-question', role: 'user' }) + assert.equal(current(), false) + finish() + await pending + assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'error') + assert.equal(h.globals.messageStateRef.current.messages.at(-1).id, 'new-question') +}) + +test('restored interrupted navigation becomes retryable without reopening any file', () => { + const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8')) + const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } } + const restored = recoverInterruptedAgentMessages([message])[0] + assert.equal(restored.codeReferenceStates.pending.status, 'error') + assert.equal(restored.codeReferenceStates.approved.status, 'opened') + assert.equal(restored.codeReferenceStates.declined.status, 'cancelled') + assert.equal(message.codeReferenceStates.pending.status, 'opening') +}) + +function elements(tree, predicate) { + if (!React.isValidElement(tree)) return [] + return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))] +} +function text(tree) { + return typeof tree === 'string' ? tree : React.isValidElement(tree) ? React.Children.toArray(tree.props.children).map(text).join('') : '' +} +const render = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentCodeReferences.tsx'), 'utf8'), {}, name => name.endsWith('/i18n') ? { t: key => key, useLocaleRender() {} } : require(name)).AgentCodeReferences +for (const dark of [true, false]) test(`consent controls use cancel-left/open-right and preserve states (${dark})`, () => { + const clicked = [] + const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`), onCancel: id => clicked.push(`cancel:${id}`) } + const buttons = elements(render(props), element => element.type === 'button') + assert.equal(clicked.length, 0) + assert.deepEqual(buttons.map(text), ['취소', '코드에서 보기']) + buttons[1].props.onClick() + assert.deepEqual(clicked, ['open:location']) + for (const button of elements(render({ ...props, busy: true }), element => element.type === 'button')) assert.equal(button.props.disabled, true) + assert.equal(elements(render({ ...props, states: { location: { status: 'cancelled' } } }), element => element.type === 'button').length, 0) + assert.equal(elements(render({ ...props, states: { location: { status: 'error', error: 'missing' } } }), element => element.props.role === 'alert').length, 1) +}) diff --git a/scripts/test-agent-plan-cancel.cjs b/scripts/test-agent-plan-cancel.cjs index 048d5cf..e93c310 100644 --- a/scripts/test-agent-plan-cancel.cjs +++ b/scripts/test-agent-plan-cancel.cjs @@ -118,6 +118,7 @@ const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync( 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, + AgentWorkDuration: () => null, agentMessageCopyText: message => message.content }).AgentMessages diff --git a/scripts/test-agent-progress.cjs b/scripts/test-agent-progress.cjs new file mode 100644 index 0000000..747047f --- /dev/null +++ b/scripts/test-agent-progress.cjs @@ -0,0 +1,157 @@ +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 compile(source, globals = {}) { + const exports = {} + vm.runInNewContext(ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } + }).outputText, { exports, DOMException, Error, ...globals }) + return exports +} +const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8')) +const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true) +const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent') +const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source))) +const plain = value => JSON.parse(JSON.stringify(value)) +const response = (toolRequests = []) => ({ provider: 'QWEN', model: 'fixture', plan: { summary: '분석 완료', explanation: '최종 설명', requestedModules: [], requestedLogs: [], changes: [], commands: [], createModule: null, actionPlan: [], toolRequests } }) +const deferred = () => { let resolve; let reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } } +const tick = () => new Promise(resolve => setImmediate(resolve)) +const result = (extra = {}) => ({ id: 'tool', name: 'files.read', status: 'ok', target: 'fixture', observedAt: new Date().toISOString(), data: '{"exists":true,"complete":true,"content":"PRIVATE_SOURCE"}', truncated: false, ...extra }) + +function harness(requestApi) { + const session = {} + let now = 1000 + class Clock extends Date { static now() { return now } } + const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null } + const messageStateRef = { current: { session } } + const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), { + ...context, session, messageStateRef, AbortController, + Date: Clock, + courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, + status: { enabled: true }, abortControllerRef: { current: null }, + setPending: value => { state.pending = value }, + setRequestStartedAt: value => { state.requestStartedAt = value }, + setActivity: value => { state.activity = value }, + setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value }, + updateMessages: update => { state.messages = update(state.messages); state.saved.push(plain(state.messages)) }, + onResponseRef: { current: () => { state.notices++ } }, requestApi + }) + return { ...api, state, messageStateRef, advance: ms => { now += ms } } +} + +test('publishes completed stages immediately without persisting progress or adding model calls', async () => { + const first = deferred(), tool = deferred(), final = deferred() + let calls = 0 + const h = harness(() => (++calls === 1 ? first.promise : final.promise)) + const running = h.requestPlan('카메라 FPS 확인', [{ name: 'Camera', files: [] }], [], { executeTool: () => tool.promise }) + assert.equal(h.state.pending, true) + assert.equal(h.state.requestStartedAt, 1000) + assert.equal(h.state.steps.length, 0) + first.resolve(response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config/camera.yaml' } }])) + await tick() + assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis']) + assert.match(h.state.activity, /files.read 조회 중/) + tool.resolve(result()) + await tick() + assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis', 'tool']) + assert.equal(h.state.steps[1].path, 'config/camera.yaml') + assert.equal(JSON.stringify(h.state.steps).includes('PRIVATE_SOURCE'), false) + assert.equal(h.state.messages.length, 1) + assert.equal(h.state.saved.flat().some(message => message.progressSteps), false) + assert.equal(calls, 2) + h.advance(20_900) + final.resolve(response()) + await running + assert.equal(h.state.steps.length, 0) + assert.equal(h.state.pending, false) + assert.equal(h.state.activity, '') + assert.equal(h.state.requestStartedAt, null) + assert.equal(h.state.messages.length, 2) + assert.equal(h.state.messages[1].plan.summary, '분석 완료') + assert.equal(h.state.messages[1].durationMs, 20_900) + assert.equal(h.state.saved.at(-1)[1].durationMs, 20_900) + assert.equal(h.state.notices, 1) +}) + +test('errors replace temporary progress with the existing error reply', async () => { + let calls = 0 + const h = harness(async () => { + if (++calls === 1) return response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }]) + throw Error('서버 오류') + }) + await h.requestPlan('확인', [{ name: 'Camera', files: [] }], [], { executeTool: async () => result({ status: 'error' }) }) + assert.equal(h.state.messages.at(-1).content, '서버 오류') + assert.equal(h.state.steps.length, 0) + assert.equal(h.state.pending, false) + assert.equal(h.state.requestStartedAt, null) +}) + +test('stopping clears progress and a late response cannot overwrite a newer request', async () => { + const old = deferred(), next = deferred() + let calls = 0 + const h = harness(() => (++calls === 1 ? old.promise : next.promise)) + const first = h.requestPlan('첫 질문', [], []) + h.stop() + assert.equal(h.state.requestStartedAt, null) + assert.equal(h.state.steps.length, 0) + assert.equal(h.state.activity, '') + const second = h.requestPlan('다음 질문', [], []) + old.resolve(response()) + await first + assert.equal(h.state.pending, true) + assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 0) + next.resolve(response()) + await second + assert.equal(h.state.pending, false) + assert.equal(h.state.steps.length, 0) + assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 1) +}) + +test('scope changes suppress late progress callbacks', async () => { + const tool = deferred() + const h = harness(async () => response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }])) + const running = h.requestPlan('확인', [], [], { executeTool: () => tool.promise }) + await tick() + h.messageStateRef.current.session = {} + const previous = h.state.steps.length + h.stop() + tool.resolve(result()) + await running + assert.equal(previous, 1) + assert.equal(h.state.steps.length, 0) + assert.equal(h.state.notices, 0) +}) + +test('source context stages report missing and limited modules and empty logs honestly', async () => { + const steps = [] + let calls = 0 + await context.resolveAgentContext({ + signal: new AbortController().signal, + initialContext: { modules: [{ name: 'Missing', files: [] }, { name: 'Limited', files: [] }], runtimeLogs: [] }, + onStep: step => steps.push(step), + requestPlan: async () => ++calls === 1 ? { ...response(), plan: { ...response().plan, requestedModules: ['Missing', 'Limited'], requestedLogs: ['Missing'] } } : response(), + loadRequestedContext: async () => ({ modules: [{ name: 'Missing', files: [], fileContext: { status: 'uninstalled' } }, { name: 'Limited', files: [{ path: 'x', content: 'PRIVATE_SOURCE' }], fileContext: { status: 'limited' } }], runtimeLogs: [] }) + }) + assert.deepEqual(plain(steps).map(step => [step.kind, step.status]), [['analysis', 'ok'], ['files', 'missing'], ['files', 'partial'], ['logs', 'empty'], ['analysis', 'ok']]) + assert.equal(JSON.stringify(steps).includes('PRIVATE_SOURCE'), false) +}) + +test('tool stages distinguish missing files, partial reads and counts without exposing raw output', async () => { + const steps = [] + const tools = [ + { id: 'missing', name: 'files.read', moduleName: 'Camera', args: { path: 'missing.yaml' } }, + { id: 'partial', name: 'files.read', moduleName: 'Camera', args: { path: 'large.py' } }, + { id: 'list', name: 'files.list', moduleName: 'Camera' }, + { id: 'search', name: 'files.search', moduleName: 'Camera', args: { query: 'fps' } } + ] + let calls = 0 + await context.resolveAgentContext({ + signal: new AbortController().signal, initialContext: { modules: [], runtimeLogs: [] }, onStep: step => steps.push(step), + requestPlan: async () => response(++calls === 1 ? tools : []), + executeTool: async tool => result({ data: JSON.stringify(tool.args?.path === 'missing.yaml' ? { exists: false } : tool.args?.path === 'large.py' ? { complete: false } : tool.name === 'files.list' ? { paths: ['a', 'b'] } : { matches: [] }) }) + }) + assert.deepEqual(plain(steps.filter(step => step.kind === 'tool')).map(step => [step.status, step.count]), [['missing', undefined], ['partial', undefined], ['ok', 2], ['ok', 0]]) +}) diff --git a/scripts/test-agent-response-reveal.cjs b/scripts/test-agent-response-reveal.cjs index 4d3d423..827e2ae 100644 --- a/scripts/test-agent-response-reveal.cjs +++ b/scripts/test-agent-response-reveal.cjs @@ -72,7 +72,7 @@ test('long answers are grouped into at most twenty steps and finish within five test('only the newly appended paragraph gets the fade class', () => { const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { - '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {} + '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {} }) const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.') const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL) @@ -168,7 +168,7 @@ for (const dark of [false, true]) { test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => { let expanded const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { - '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, + '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}, react: { ...React, useEffect() {}, useState(initial) { if (expanded === undefined) expanded = initial return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }] @@ -192,7 +192,7 @@ for (const dark of [false, true]) { test('short answers do not show an unnecessary collapse button', () => { const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { - '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, + '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}, react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] } }) const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true }) diff --git a/scripts/test-agent-work-duration.cjs b/scripts/test-agent-work-duration.cjs new file mode 100644 index 0000000..67db19e --- /dev/null +++ b/scripts/test-agent-work-duration.cjs @@ -0,0 +1,87 @@ +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 harness(initialNow = 1000) { + let now = initialNow, state, effect, interval, cleared = 0 + const windowEvents = new Map(), documentEvents = new Map() + class Clock extends Date { static now() { return now } } + const exports = {} + vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } + }).outputText, { + exports, Date: Clock, + window: { + setInterval(callback, delay) { assert.equal(delay, 1000); interval = callback; return 7 }, + clearInterval(id) { assert.equal(id, 7); interval = undefined; cleared++ }, + addEventListener: (name, callback) => windowEvents.set(name, callback), + removeEventListener: name => windowEvents.delete(name) + }, + document: { + addEventListener: (name, callback) => documentEvents.set(name, callback), + removeEventListener: name => documentEvents.delete(name) + }, + require(name) { + if (name === '../i18n') return { t: (key, values) => key.replace(/\{\{seconds\}\}/g, values.seconds), useLocaleRender() {} } + if (name === 'react') return { ...React, + useState(initial) { state ??= typeof initial === 'function' ? initial() : initial; return [state, value => { state = value }] }, + useEffect(callback) { effect = callback } + } + return require(name) + } + }) + return { + render: props => renderToStaticMarkup(exports.AgentWorkDuration({ dark: true, ...props })), + mount: () => effect(), advance: ms => { now += ms }, tick: () => interval?.(), + visible: () => documentEvents.get('visibilitychange')?.(), focus: () => windowEvents.get('focus')?.(), + interval: () => interval, cleared: () => cleared, + listeners: () => windowEvents.size + documentEvents.size + } +} + +test('running duration is based on start time, not the number of interval ticks', () => { + const h = harness() + assert.match(h.render({ startedAt: 1000 }), /0초 동안 작업 중입니다/) + const cleanup = h.mount() + h.advance(12_900) + h.tick() + assert.match(h.render({ startedAt: 1000 }), /12초 동안 작업 중입니다/) + h.advance(60_000) + h.visible() + assert.match(h.render({ startedAt: 1000 }), /72초 동안 작업 중입니다/) + h.advance(5_000) + h.focus() + assert.match(h.render({ startedAt: 1000 }), /77초 동안 작업 중입니다/) + assert.equal(h.listeners(), 2) + cleanup() + assert.equal(h.interval(), undefined) + assert.equal(h.listeners(), 0) + assert.equal(h.cleared(), 1) +}) + +test('returning to a remounted chat shows the full elapsed time immediately', () => { + const h = harness(74_000) + assert.match(h.render({ startedAt: 1000 }), /73초 동안 작업 중입니다/) +}) + +test('completed duration stays fixed and never starts a timer', () => { + const h = harness() + assert.match(h.render({ startedAt: 1000, durationMs: 20_900 }), /20초 동안 작업함/) + assert.equal(h.mount(), undefined) + h.advance(60_000) + h.tick() + assert.match(h.render({ durationMs: 20_900 }), /20초 동안 작업함/) + assert.equal(h.interval(), undefined) + assert.equal(h.listeners(), 0) + assert.match(h.render({ durationMs: 0 }), /0초 동안 작업함/) +}) + +test('legacy replies and invalid timing do not invent an elapsed duration', () => { + const h = harness() + for (const durationMs of [undefined, -1, NaN, Infinity]) assert.equal(h.render({ durationMs }), '') + assert.match(h.render({ startedAt: 2000 }), /0초 동안 작업 중입니다/) +}) diff --git a/scripts/test-ai-response-presentation.cjs b/scripts/test-ai-response-presentation.cjs index 4cd3357..52b0027 100644 --- a/scripts/test-ai-response-presentation.cjs +++ b/scripts/test-ai-response-presentation.cjs @@ -16,19 +16,30 @@ function compile(source, overrides = {}, globals = {}) { } const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), { - '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {} + '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {} }) 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 ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content) const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name }))) +const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), { + '../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} } +}) +const { AgentProgressBubble } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentProgressBubble.tsx'), 'utf8'), { + '../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }, + react: { ...React, useEffect() {}, useRef: () => ({ current: null }) }, + './AgentWorkDuration': { AgentWorkDuration } +}) const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, { t: key => key, useLocaleRender() {}, ChatResponseSkeleton: widgets.ChatResponseSkeleton, ChatEmptyState: widgets.ChatEmptyState, ProgressiveChatResponse, ChatImageAttachments, + AgentProgressBubble, + AgentWorkDuration, MessageCopyButton: () => null, + agentMessageCopyText: message => message.content, Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg') }) @@ -76,6 +87,33 @@ test('AI answer forwards reveal timing and collapse settings to the same present assert.match(html, /전체 답변/) }) +for (const dark of [true, false]) { + test(`agent progress accumulates only during the request and is replaced by the final reply (${dark ? 'dark' : 'light'})`, () => { + const progressSteps = [ + { id: 'analysis-0', kind: 'analysis', status: 'ok', round: 1, requests: 1 }, + { id: 'tool-0-0', kind: 'tool', name: 'files.list', moduleName: 'Camera', status: 'ok', count: 3 }, + { id: 'tool-1-0', kind: 'tool', name: 'files.read', moduleName: 'Camera', path: 'config/camera.yaml', status: 'missing' }, + { id: 'tool-1-1', kind: 'tool', name: 'logs.read', moduleName: 'Camera', status: 'error' }, + { id: 'tool-1-2', kind: 'tool', name: 'files.search', moduleName: 'Camera', status: 'partial', count: 2 } + ] + const props = { dark, enabled: true, statusLoading: false, pending: true, activity: '조회 결과 분석 중', progressSteps, messages: [] } + const html = renderToStaticMarkup(messages.AgentMessages(props)) + assert.equal((html.match(/data-agent-progress="true"/g) ?? []).length, 1) + assert.match(html, /1차 분석 완료 · 추가 조회 1건/) + assert.match(html, /Camera · 파일 목록 · 조회 완료 · 3건/) + assert.match(html, /Camera · 파일 읽기 · config\/camera.yaml · 대상 없음/) + assert.match(html, /Camera · 로그 조회 · 조회 실패/) + assert.match(html, /Camera · 파일 검색 · 일부 결과 · 2건/) + assert.match(html, /조회 결과 분석 중/) + assert.equal((html.match(/agent-response-fade/g) ?? []).length, 5) + const final = renderToStaticMarkup(messages.AgentMessages({ ...props, pending: false, messages: [{ id: 'answer', role: 'assistant', content: '최종 답변입니다.', durationMs: 20_900 }] })) + assert.doesNotMatch(final, /data-agent-progress|config\/camera.yaml|진행 상황/) + assert.match(final, /최종 답변입니다/) + assert.match(final, /20초 동안 작업함/) + assert.ok(final.indexOf('20초 동안 작업함') < final.indexOf('최종 답변입니다')) + }) +} + for (const dark of [false, true]) { for (const [mode, expected] of [ ['AiMessages', /AI에게 질문할내용을 입력하세요/], diff --git a/scripts/test-chat-clear-confirmation.cjs b/scripts/test-chat-clear-confirmation.cjs index ea356d8..7b72a6e 100644 --- a/scripts/test-chat-clear-confirmation.cjs +++ b/scripts/test-chat-clear-confirmation.cjs @@ -39,6 +39,9 @@ function setup(overrides = {}, browser = {}) { '../../components/ChatWidgets': { QaInputBox }, '../../components/ChatTextContextMenu': { ChatTextContextMenu }, '../../components/ChatImageAttachments': {}, + '../../components/AgentProgressBubble': {}, + '../../components/AgentWorkDuration': {}, + '../../components/AgentCodeReferences': {}, 'react-dom': { createPortal: child => child }, react: { ...React, diff --git a/scripts/test-chat-input-autosize.cjs b/scripts/test-chat-input-autosize.cjs index 3554451..ef748d1 100644 --- a/scripts/test-chat-input-autosize.cjs +++ b/scripts/test-chat-input-autosize.cjs @@ -36,6 +36,7 @@ function harness() { if (name === './chatAttachments') return {} if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null } if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null } + if (name === './ChatCodeBlock') return {} if (name === 'react') return { ...React, useState(initial) { diff --git a/scripts/test-chat-markdown.cjs b/scripts/test-chat-markdown.cjs new file mode 100644 index 0000000..caf3cf5 --- /dev/null +++ b/scripts/test-chat-markdown.cjs @@ -0,0 +1,111 @@ +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), clearTimeout, setTimeout, ...globals + }) + return exports +} +const i18n = { t: key => key, useLocaleRender() {} } +const block = compile('../src/renderer/src/components/ChatCodeBlock.tsx', { '../i18n': i18n }) +const { MarkdownMessage } = compile('../src/renderer/src/components/ChatWidgets.tsx', { + '../i18n': i18n, './ChatCodeBlock': block, './chatAttachments': {}, './ChatImageAttachments': {}, './ChatTextContextMenu': {} +}) +const render = (content, dark = true, extra = {}) => renderToStaticMarkup(React.createElement(MarkdownMessage, { content, dark, ...extra })) + +for (const dark of [true, false]) { + test(`renders numbered/nested/task lists without losing the starting number (${dark})`, () => { + const html = render('3. 설정 확인\n - 속도 제한\n - 조향 제한\n4. 결과 확인\n\n- [x] 완료\n- [ ] 대기', dark) + assert.match(html, /
    { + const html = render('| 모듈 | 속도 |\n| --- | ---: |\n| Control | `0.3 m/s` |', dark) + assert.match(html, /data-chat-table/) + assert.match(html, /tabindex="0" role="region" aria-label="표"/) + assert.match(html, /overflow-x-auto/) + assert.match(html, / { + const html = render('```python\ndef speed():\n return 0.3\n```', dark) + assert.match(html, /data-chat-code-block/) + assert.ok(html.includes(`data-code-theme="${dark ? 'dark' : 'light'}"`)) + assert.match(html, />Python<\/span>/) + assert.match(html, /aria-label="코드 복사"/) + assert.match(html, /hljs-keyword/) + assert.match(html, /hljs-number/) + assert.match(html, /\n { + for (const language of ['', 'unknown-language']) { + const html = render('```' + language + '\n unformatted \n```') + assert.match(html, /data-chat-code-block/) + assert.ok(html.includes(language || '일반 텍스트')) + assert.ok(html.includes(' unformatted <value>')) + assert.doesNotMatch(html, /hljs-/) + } +}) + +test('HTML-looking code is rendered as escaped text, never live elements', () => { + const html = render('```html\n\n\n```') + assert.doesNotMatch(html, / { + const markdown = '설명\n\n```yaml\nspeed: 0.3\n```\n\n| 키 | 값 |\n| --- | --- |\n| speed | 0.3 |' + const html = render(markdown, true, { fadeFrom: markdown.indexOf('| 키') }) + assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1) + assert.match(html, /overflow-x-auto rounded-md agent-response-fade/) +}) + +test('oversized code skips tokenization but still preserves all content', () => { + const source = '# comment\n'.repeat(6000) + const html = renderToStaticMarkup(React.createElement(block.ChatCodeBlock, { source, language: 'python', dark: true })) + assert.doesNotMatch(html, /hljs-comment/) + assert.equal((html.match(/# comment/g) ?? []).length, 6000) +}) + +function elements(tree, predicate) { + if (!React.isValidElement(tree)) return [] + return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))] +} +for (const fails of [false, true]) test(`code copy preserves the full source and reports clipboard ${fails ? 'failure' : 'success'}`, async () => { + let state = 'idle', copied, timer + const api = compile('../src/renderer/src/components/ChatCodeBlock.tsx', { + '../i18n': i18n, + react: { ...React, useState: () => [state, value => { state = value }], useMemo: fn => fn(), useRef: () => ({ current: null }), useEffect() {} } + }, { navigator: { clipboard: { writeText: async text => { if (fails) throw Error('denied'); copied = text } } }, setTimeout: callback => { timer = callback; return 1 }, clearTimeout() {} }) + const source = '\t# 한글 주석\n speed = 0.3\n\n' + const renderBlock = () => api.ChatCodeBlock({ source, language: 'python', dark: true }) + const button = elements(renderBlock(), element => element.type === 'button')[0] + let stopped = false + button.props.onClick({ stopPropagation() { stopped = true } }) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(stopped, true) + assert.equal(state, fails ? 'error' : 'copied') + if (!fails) assert.equal(copied, source) + assert.equal(elements(renderBlock(), element => element.type === 'button')[0].props['aria-label'], fails ? '코드를 복사하지 못했습니다.' : '복사됨') + timer() + assert.equal(state, 'idle') +}) diff --git a/scripts/test-chat-text-menu.cjs b/scripts/test-chat-text-menu.cjs index ac157b4..1b751f7 100644 --- a/scripts/test-chat-text-menu.cjs +++ b/scripts/test-chat-text-menu.cjs @@ -125,7 +125,7 @@ for (const selected of [false, true]) { let prevented = false let stopped = false const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', { - '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null }, + '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null }, './ChatCodeBlock': {}, react: { ...React, useState: initial => [initial, value => { if (value?.kind === 'input') options = value }], useEffect() {}, useLayoutEffect() {}, useRef: () => ({ current: null }) } }, { window: { chatTextMenu: { show: async input => { options = input; return null } } } }) const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} }) diff --git a/scripts/test-font-fallback.cjs b/scripts/test-font-fallback.cjs new file mode 100644 index 0000000..5aaaa0a --- /dev/null +++ b/scripts/test-font-fallback.cjs @@ -0,0 +1,61 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const crypto = require('node:crypto') +const postcss = require('postcss') + +const root = path.resolve(__dirname, '..') +const source = fs.readFileSync(path.join(root, 'src/renderer/src/styles/nanumSquare.css'), 'utf8') +const css = postcss.parse(source) +const faces = [] +css.walkAtRules('font-face', rule => { + faces.push(Object.fromEntries(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node.value]))) +}) + +function codepoints(range) { + const result = new Set() + let previous = -1 + for (const entry of range.split(',')) { + const match = entry.trim().match(/^U\+([0-9A-F]+)(?:-([0-9A-F]+))?$/) + assert.ok(match, `Invalid unicode-range: ${entry}`) + const start = parseInt(match[1], 16) + const end = parseInt(match[2] ?? match[1], 16) + assert.ok(start > previous && end >= start && end <= 0x10ffff) + for (let code = start; code <= end; code++) result.add(code) + previous = end + } + return result +} + +test('coverage is tied to the original bundled font bytes for all four weights', () => { + assert.equal(faces.length, 4) + assert.deepEqual(faces.map(face => face['font-weight']), ['300', '400', '700', '800']) + for (const face of faces) { + assert.equal(face['font-family'], "'NanumSquare'") + assert.equal(face['font-display'], 'swap') + const filename = face.src.match(/\/([^/]+\.woff2)'/)[1] + const hash = crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'src/renderer/public/assets/fonts', filename))).digest('hex') + assert.ok(source.includes(`${filename} sha256: ${hash}`), 'Regenerate coverage if a font changes') + } +}) + +for (const face of faces) { + test(`weight ${face['font-weight']} preserves visible Hangul and lets blank glyphs fall back`, () => { + const included = codepoints(face['unicode-range']) + assert.equal([...included].filter(code => code >= 0xac00 && code <= 0xd7a3).length, 2479) + for (const char of '가모한글학생SLAM0123 →') assert.ok(included.has(char.codePointAt(0)), `Keep ${char}`) + for (const char of ['몯', '힣', '\u2022']) assert.equal(included.has(char.codePointAt(0)), false, `Let ${char} use fallback`) + for (const code of [0x20, 0x3000, 0x3164]) assert.ok(included.has(code), 'Preserve intentional spacing') + }) +} + +test('UI keeps its original default font and native fallback without Neo', () => { + const entry = fs.readFileSync(path.join(root, 'src/renderer/src/index.css'), 'utf8') + assert.match(entry, /@import "\.\/styles\/nanumSquare\.css"/) + assert.match(entry, /font-family: 'NanumSquare', Inter, system-ui, sans-serif/) + assert.doesNotMatch(entry + source, /NanumSquareNeo/) + const directFaces = [] + postcss.parse(entry).walkAtRules('font-face', rule => directFaces.push(rule)) + assert.equal(directFaces.length, 0, 'Unrestricted duplicate faces would suppress fallback') +}) diff --git a/scripts/test-platform-availability.cjs b/scripts/test-platform-availability.cjs new file mode 100644 index 0000000..4c1f5e1 --- /dev/null +++ b/scripts/test-platform-availability.cjs @@ -0,0 +1,86 @@ +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 source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) +const names = ['isRemotePlatformStatusPending', 'filteredPlatformOptions', 'platformFilterCounts'] +const declarations = new Map() +function visit(node) { + if (ts.isVariableDeclaration(node) && names.includes(node.name.getText(source))) { + declarations.set(node.name.getText(source), node.getText(source)) + } + ts.forEachChild(node, visit) +} +visit(source) +for (const name of names) assert.ok(declarations.has(name), `missing ${name}`) +const filterScript = ts.transpileModule(`${names.map(name => `const ${declarations.get(name)}`).join('\n')} + result = { counts: platformFilterCounts, ids: filteredPlatformOptions.map(platform => platform.id) }`, { + compilerOptions: { target: ts.ScriptTarget.ES2022 } +}).outputText +const controlSource = fs.readFileSync(require.resolve('../src/renderer/src/features/platforms/platformControl.ts'), 'utf8') +const control = {} +vm.runInNewContext(ts.transpileModule(controlSource, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { + exports: control, + require: name => { + assert.equal(name, '../../config/appConfig') + return { LMS_API_BASE_URL: 'http://unused.invalid' } + } +}) + +function classify(platformOptions, checkedIds = ['mini'], platformFilter = 'all') { + const context = { + platformOptions, platformFilter, + checkedRemotePlatformStatusIds: new Set(checkedIds), + isPlatformUnavailable: control.isPlatformUnavailable + } + vm.runInNewContext(filterScript, context) + return JSON.parse(JSON.stringify(context.result)) +} + +const simulation = { id: 'sim', platformKey: 'futuredrive-local-pc', state: 'ready', status: '사용 가능', action: '시작' } +const mini = { id: 'mini', platformKey: 'mkmini', state: 'ready', status: '사용 가능', action: '제어권 요청', connection: { headscaleOnline: true, agentHealthy: true } } + +test('disconnected ready platform counts as unavailable and appears only in that tab', () => { + const disconnected = { ...mini, status: '연결 끊김', connection: { headscaleOnline: false, agentHealthy: false } } + const platforms = [simulation, disconnected] + assert.deepEqual(classify(platforms), { counts: { all: 2, available: 1, unavailable: 1 }, ids: ['sim', 'mini'] }) + assert.deepEqual(classify(platforms, ['mini'], 'available').ids, ['sim']) + assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini']) +}) + +test('offline network, unhealthy agent and explicit unavailability all exclude ready platforms', () => { + for (const patch of [ + { connection: { headscaleOnline: false, agentHealthy: true } }, + { connection: { headscaleOnline: true, agentHealthy: false } }, + { connection: undefined, status: '연결 끊김' }, + { connection: undefined, status: '연결 불가' }, + { connection: undefined, action: '연결 불가' } + ]) { + const platforms = [simulation, { ...mini, ...patch }] + assert.deepEqual(classify(platforms).counts, { all: 2, available: 1, unavailable: 1 }) + assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini']) + } +}) + +test('pending remote status remains in All without prematurely entering either availability tab', () => { + const platforms = [simulation, mini] + assert.deepEqual(classify(platforms, []).counts, { all: 2, available: 1, unavailable: 0 }) + assert.deepEqual(classify(platforms, [], 'available').ids, ['sim']) + assert.deepEqual(classify(platforms, [], 'unavailable').ids, []) +}) + +test('reconnecting moves a platform back to available without changing All', () => { + assert.deepEqual(classify([simulation, mini]).counts, { all: 2, available: 2, unavailable: 0 }) + assert.deepEqual(classify([simulation, mini], ['mini'], 'available').ids, ['sim', 'mini']) + assert.deepEqual(classify([simulation, mini], ['mini'], 'unavailable').ids, []) +}) + +test('locked, requested and reserved platform classifications are preserved', () => { + for (const state of ['locked', 'request', 'reserved']) { + const platforms = [simulation, { ...mini, state }] + assert.deepEqual(classify(platforms).counts, { all: 2, available: 1, unavailable: 1 }) + assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini']) + } +}) diff --git a/scripts/test-studio-theme.cjs b/scripts/test-studio-theme.cjs index 7845746..2d9c8a3 100644 --- a/scripts/test-studio-theme.cjs +++ b/scripts/test-studio-theme.cjs @@ -46,3 +46,18 @@ test('theme synchronizes across windows without writing runtime settings', () => events.get('storage')({ key: null }) assert.equal(api.useStudioTheme(), 'dark') }) + +test('module content horizontal scrollbar uses the shared workspace scrollbar style', () => { + const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(path.join(root, 'ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + const scrollClasses = [] + function visit(node) { + if (ts.isJsxAttribute(node) && node.name.getText(source) === 'className' && node.initializer && ts.isStringLiteral(node.initializer)) { + const classes = node.initializer.text.split(/\s+/) + if (classes.includes('overflow-x-auto')) scrollClasses.push(classes) + } + ts.forEachChild(node, visit) + } + visit(source) + assert.ok(scrollClasses.length > 0) + for (const classes of scrollClasses) assert.ok(classes.includes('thin-x-scrollbar')) +}) diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index 26bd908..ce26362 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -24,11 +24,12 @@ #miniMapToggle { position:absolute; right:16px; - bottom:240px; + bottom:calc(16px + 220px + 2px); z-index:11; min-height:28px; padding:0 10px; border:1px solid rgba(255,255,255,.22); + border-radius:6px 6px 0 0; background:rgba(17,24,39,.42); color:rgba(255,255,255,.88); font:12px sans-serif; @@ -109,6 +110,7 @@ display:none; } #keyGuide { + display:none; position:absolute; left:16px; bottom:16px; @@ -127,7 +129,7 @@ #rosTopicStatusWrap { position:absolute; left:16px; - bottom:54px; + bottom:16px; z-index:10; width:178px; } @@ -252,6 +254,7 @@ #cameraPresetSelect { min-height:32px; border:1px solid rgba(255,255,255,.22); + border-radius:6px; background:rgba(17,24,39,.42); color:rgba(255,255,255,.88); font:12px sans-serif; @@ -261,29 +264,43 @@ box-shadow:0 10px 26px rgba(0,0,0,.16); transition:background .15s ease, border-color .15s ease, color .15s ease; } - #downloadMap, - #pathToggle, - #sensorToggle, - #poseToggle, - #goalToggle, - #obstacleToggle { - padding:0 11px; - } - #downloadMap { - display:inline-flex; - align-items:center; - gap:6px; - } + .viewer-icon-button { + display:grid; + place-items:center; + width:34px; + height:32px; + flex:0 0 34px; + padding:0; + } + .camera-preset-wrap { + position:relative; + width:34px; + height:32px; + flex:0 0 34px; + } + .camera-preset-wrap > svg { + position:absolute; + inset:0; + margin:auto; + color:rgba(255,255,255,.88); + pointer-events:none; + } #cameraPresetSelect { - min-width:86px; - padding:0 24px 0 9px; - outline:none; + width:100%; + height:100%; + padding:0; + color:transparent; appearance:none; - background-image:linear-gradient(45deg, transparent 50%, rgba(255,255,255,.7) 50%), linear-gradient(135deg, rgba(255,255,255,.7) 50%, transparent 50%); - background-position:calc(100% - 13px) 14px, calc(100% - 8px) 14px; - background-size:5px 5px, 5px 5px; - background-repeat:no-repeat; } + #cameraPresetSelect option { + color:rgba(255,255,255,.88); + background:#202020; + } + .viewer-icon-button:focus-visible, + #cameraPresetSelect:focus-visible { + outline:2px solid #93c5fd; + outline-offset:2px; + } #downloadMap:hover, #pathToggle:hover, #sensorToggle:hover, @@ -296,6 +313,7 @@ border-color:rgba(255,255,255,.34); color:#fff; } + #cameraPresetSelect:hover { color:transparent; } #downloadMap:disabled { opacity:.45; cursor:not-allowed; @@ -374,35 +392,33 @@ .nav2-slam #goalToggle, .nav2-slam #poseToggle { display:none; } .nav2-navigation #downloadWrap { display:none; } .nav2-navigation #topLeftControls { display:block; } - .nav2-navigation #keyGuide { display:none; } - .nav2-navigation #rosTopicStatusWrap { bottom:16px; } .real-runtime #topLeftControls { display:none; } - .real-runtime #keyGuide { display:none; } - .real-teleop:not(.editor-mode) #keyGuide { display:block; } - .real-teleop #rosTopicStatusWrap { bottom:50px; }
    - +
    - +
    - - - + + - - + + +
    + +
    diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7e78967..ac14090 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -60,7 +60,8 @@ import { useQaChat } from './features/workspace/useQaChat' import { useAiAgent } from './features/workspace/useAiAgent' import { validateAgentOperation, validateAgentTool, type AgentToolRequest, type AgentToolResult } from '../../shared/agentTools' import { AGENT_PRACTICE_PROFILES, inspectAgentPractice, prepareAgentPractice } from './features/workspace/agentPractice' -import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent' +import type { AgentCodeReference, AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent' +import { revealAgentCodeReference, validateCodeReference } from './features/workspace/agentCodeNavigation' import type { ChatAttachment } from './components/chatAttachments' import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime' import { useSourceControl } from './features/workspace/useSourceControl' @@ -632,12 +633,16 @@ function LocalPcWorkspaceMockup({ statusLoading: agentStatusLoading, pending: agentPending, activity: agentActivity, + progressSteps: agentProgressSteps, + requestStartedAt: agentRequestStartedAt, messages: agentMessages, persistenceError: agentPersistenceError, requestPlan: requestAgentPlan, applyPlan: applyAgentPlan, cancelPlan: cancelAgentPlan, updateCommandExecution: updateAgentCommandExecution, + openCodeReference: openAgentCodeReference, + cancelCodeReference: cancelAgentCodeReference, stop: stopAgent, clear: clearAgentConversation } = useAiAgent({ @@ -855,6 +860,7 @@ function LocalPcWorkspaceMockup({ const editorPaneRef = useRef(null) const editorRef = useRef[0] | null>(null) const decorationIdsRef = useRef([]) + const agentCodeDecorationsRef = useRef[0]['createDecorationsCollection']> | null>(null) const [leftPanelWidth, setLeftPanelWidth] = useState(310) const [rightPanelWidth, setRightPanelWidth] = useState(310) const [projectTreeWidth, setProjectTreeWidth] = useState(280) @@ -2446,6 +2452,10 @@ function LocalPcWorkspaceMockup({ setActiveEditorFile(file) setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file])) } + const agentNavigationScope = JSON.stringify([studentEmail, courseKey, weekNumber, platformId, clonePathPreview, remoteWorkspace, remoteWorkspaceTargetKey]) + const agentNavigationRef = useRef({ scope: agentNavigationScope, file: currentActiveEditorFile, ready: false, visible: workspaceVisible }) + agentNavigationRef.current = { scope: agentNavigationScope, file: currentActiveEditorFile, ready: activeIdeActivity === 'workspace' && !activeAgentDiffPreview && !remoteEditorFile.loading && !remoteEditorFile.error, visible: workspaceVisible } + useEffect(() => () => { agentCodeDecorationsRef.current?.clear(); agentCodeDecorationsRef.current = null }, [agentNavigationScope, currentActiveEditorFile, activeIdeActivity, activeAgentDiffPreview?.id, workspaceVisible]) const loadExampleCodeFiles = async (): Promise => { try { const token = accessToken ? await getFreshAccessToken() : '' @@ -3680,6 +3690,33 @@ function LocalPcWorkspaceMockup({ onTitlebarNotice(error instanceof Error ? error.message : '모듈 파일을 읽지 못했습니다.', 'error') } } + const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise => { + if (agentActionControllerRef.current || agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.') + validateCodeReference(reference) + const scope = agentNavigationScope + const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible + const module = availableModules.find(item => item.name === reference.moduleName) + if (!module) throw new Error('코드 위치의 모듈을 찾을 수 없습니다. 다시 분석을 요청해주세요.') + const file = `modules/${module.name}/${reference.path}` + if (!isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.') + const snapshot = agentEditorFilesRef.current[file] + const result = remoteWorkspace + ? await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, file, module.folderName) + : { content: (await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)).files[file] } + if (!isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.') + if (typeof result.content !== 'string') throw new Error('코드 파일을 읽지 못했습니다. 다시 분석을 요청해주세요.') + setEditorFiles(files => { + // Preserve any editor buffer already open, including edits made during the read. + if (files[file] !== undefined || files[file] !== snapshot) return files + return { ...files, [file]: result.content } + }) + setSavedEditorFiles(files => ({ ...files, [file]: result.content })) + if (selectedModule?.name !== module.name) openModuleSource(module) + else setActiveIdeActivity('workspace') + openEditorFile(file) + agentCodeDecorationsRef.current?.clear() + agentCodeDecorationsRef.current = await revealAgentCodeReference({ file, reference, isCurrent, getEditor: () => editorRef.current, getActiveFile: () => agentNavigationRef.current.file, isReady: () => agentNavigationRef.current.ready }) + } const openAgentChangePreview = async (messageId: string, change: AgentFileChange): Promise => { const module = availableModules.find((item) => item.name === change.moduleName) ?? (selectedModule?.name === change.moduleName ? selectedModule : null) @@ -4719,6 +4756,8 @@ function LocalPcWorkspaceMockup({ agentStatusLoading={agentStatusLoading} agentPending={agentPending} agentActivity={agentActivity} + agentProgressSteps={agentProgressSteps} + agentRequestStartedAt={agentRequestStartedAt} agentActionBusy={agentActionBusy || agentApplying} agentPersistenceError={agentPersistenceError} aiPending={aiPending} @@ -4752,6 +4791,8 @@ function LocalPcWorkspaceMockup({ if (agentActionControllerRef.current || agentPending) return if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId) }} + onOpenAgentCodeReference={(messageId, referenceId) => void openAgentCodeReference(messageId, referenceId, openWorkspaceAgentCodeReference)} + onCancelAgentCodeReference={cancelAgentCodeReference} onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => { await applyWorkspaceAgentPlan(plan) closeAgentDiffPreviewsForMessage(messageId) @@ -5545,14 +5586,14 @@ export default function App(): React.JSX.Element { ) const filteredPlatformOptions = platformOptions.filter((platform) => { const pending = isRemotePlatformStatusPending(platform) - if (platformFilter === 'available') return platform.state === 'ready' && !pending - if (platformFilter === 'unavailable') return platform.state !== 'ready' && !pending + if (platformFilter === 'available') return platform.state === 'ready' && !isPlatformUnavailable(platform) && !pending + if (platformFilter === 'unavailable') return (platform.state !== 'ready' || isPlatformUnavailable(platform)) && !pending return true }) const platformFilterCounts = { all: platformOptions.length, - available: platformOptions.filter((platform) => platform.state === 'ready' && !isRemotePlatformStatusPending(platform)).length, - unavailable: platformOptions.filter((platform) => platform.state !== 'ready' && !isRemotePlatformStatusPending(platform)).length + available: platformOptions.filter((platform) => platform.state === 'ready' && !isPlatformUnavailable(platform) && !isRemotePlatformStatusPending(platform)).length, + unavailable: platformOptions.filter((platform) => (platform.state !== 'ready' || isPlatformUnavailable(platform)) && !isRemotePlatformStatusPending(platform)).length } const localPcPlatform = platformOptions.find((platform) => platform.platformKey === 'futuredrive-local-pc') const openPlatformSettings = (platform: PlatformOption): void => { diff --git a/src/renderer/src/components/AgentCodeReferences.tsx b/src/renderer/src/components/AgentCodeReferences.tsx new file mode 100644 index 0000000..c9130a5 --- /dev/null +++ b/src/renderer/src/components/AgentCodeReferences.tsx @@ -0,0 +1,38 @@ +import { FileCode2, Loader2, X } from 'lucide-react' +import { t, useLocaleRender } from '../i18n' +import type { AgentCodeReference, AgentCodeReferenceState } from '../features/workspace/useAiAgent' + +export function AgentCodeReferences({ references, states, dark, busy, onOpen, onCancel }: { + references: AgentCodeReference[] + states?: Record + dark: boolean + busy?: boolean + onOpen?: (id: string) => void + onCancel?: (id: string) => void +}): React.JSX.Element { + useLocaleRender() + return
    + {references.map(reference => { + const state = states?.[reference.id] + const opening = state?.status === 'opening' + const cancelled = state?.status === 'cancelled' + return
    +
    + +
    +

    {reference.moduleName}/{reference.path}

    +

    {t('{{start}}~{{end}}줄', { start: reference.startLine, end: reference.endLine })}

    +

    {reference.reason}

    +
    +
    + {state?.error &&

    {t(state.error)}

    } +
    + {cancelled ? {t('취소됨')} : <> + {state?.status !== 'opened' && } + + } +
    +
    + })} +
    +} diff --git a/src/renderer/src/components/AgentProgressBubble.tsx b/src/renderer/src/components/AgentProgressBubble.tsx new file mode 100644 index 0000000..73a74df --- /dev/null +++ b/src/renderer/src/components/AgentProgressBubble.tsx @@ -0,0 +1,58 @@ +import { CheckCircle2, CircleAlert, XCircle } from 'lucide-react' +import { useEffect, useRef } from 'react' +import { t, useLocaleRender } from '../i18n' +import type { AgentProgressStep } from '../features/workspace/agentContext' +import { AgentWorkDuration } from './AgentWorkDuration' + +function stepLabel(step: AgentProgressStep): string { + if (step.kind === 'analysis') return [ + t('{{round}}차 분석 완료', { round: step.round ?? 1 }), + step.requests ? t('추가 조회 {{count}}건', { count: step.requests }) : '' + ].filter(Boolean).join(' · ') + if (step.kind === 'image') return `${t('이미지 업로드 완료')} · ${step.name}` + const tools: Record = { + 'modules.list': t('모듈 목록'), + 'files.list': t('파일 목록'), + 'files.search': t('파일 검색'), + 'files.read': t('파일 읽기'), + 'logs.read': t('로그 조회'), + 'network.inspect': t('네트워크 조회'), + 'ros.inspect': t('ROS 상태 조회'), + 'practice.inspect': t('실습 구성 조회') + } + const status = step.status === 'error' ? t('조회 실패') : step.status === 'partial' ? t('일부 결과') : step.status === 'empty' ? t('조회 결과 없음') : step.status === 'missing' ? t('대상 없음') : t('조회 완료') + return [ + step.moduleName, + step.kind === 'files' ? t('소스 파일') : step.kind === 'logs' ? t('로그 조회') : tools[step.name ?? ''] ?? step.name, + step.path, + status, + step.count !== undefined ? t('{{count}}건', { count: step.count }) : '' + ].filter(Boolean).join(' · ') +} + +export function AgentProgressBubble({ dark, steps, activity, startedAt }: { dark: boolean; steps: AgentProgressStep[]; activity?: string; startedAt?: number | null }): React.JSX.Element { + useLocaleRender() + const listRef = useRef(null) + useEffect(() => { + const list = listRef.current + if (list) list.scrollTop = list.scrollHeight + }, [steps.length]) + return ( +
    + +

    {t('진행 상황')}

    + {steps.length > 0 &&
      + {steps.map(step =>
    1. + {step.status === 'error' ? : step.status !== 'ok' ? : } + {stepLabel(step)} +
    2. )} +
    } +

    {activity ? t(activity) : t('요청 분석 중')}

    + +
    + ) +} diff --git a/src/renderer/src/components/AgentWorkDuration.tsx b/src/renderer/src/components/AgentWorkDuration.tsx new file mode 100644 index 0000000..b40f74b --- /dev/null +++ b/src/renderer/src/components/AgentWorkDuration.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react' +import { t, useLocaleRender } from '../i18n' + +export function AgentWorkDuration({ startedAt, durationMs, dark }: { startedAt?: number | null; durationMs?: number; dark: boolean }): React.JSX.Element | null { + useLocaleRender() + const [now, setNow] = useState(Date.now) + const completed = typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0 + const running = !completed && typeof startedAt === 'number' && Number.isFinite(startedAt) + useEffect(() => { + if (!running) return + const update = (): void => setNow(Date.now()) + update() + const timer = window.setInterval(update, 1000) + document.addEventListener('visibilitychange', update) + window.addEventListener('focus', update) + return () => { + window.clearInterval(timer) + document.removeEventListener('visibilitychange', update) + window.removeEventListener('focus', update) + } + }, [running, startedAt]) + if (!running && !completed) return null + const seconds = Math.floor(Math.max(0, completed ? durationMs : now - startedAt!) / 1000) + return

    + {completed ? t('{{seconds}}초 동안 작업함', { seconds }) : t('{{seconds}}초 동안 작업 중입니다', { seconds })} +

    +} diff --git a/src/renderer/src/components/ChatCodeBlock.tsx b/src/renderer/src/components/ChatCodeBlock.tsx new file mode 100644 index 0000000..8a10244 --- /dev/null +++ b/src/renderer/src/components/ChatCodeBlock.tsx @@ -0,0 +1,63 @@ +import { Check, Copy, FileCode2, X } from 'lucide-react' +import { common, createLowlight } from 'lowlight' +import type React from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { t, useLocaleRender } from '../i18n' + +const highlighter = createLowlight(common) +const languageLabels: Record = { + python: 'Python', py: 'Python', yaml: 'YAML', yml: 'YAML', json: 'JSON', + cpp: 'C++', 'c++': 'C++', c: 'C', javascript: 'JavaScript', js: 'JavaScript', + typescript: 'TypeScript', ts: 'TypeScript', bash: 'Bash', sh: 'Bash', shell: 'Shell', + xml: 'XML', html: 'HTML', css: 'CSS', sql: 'SQL', markdown: 'Markdown', md: 'Markdown', +} +type HighlightNode = ReturnType['children'][number] + +function renderToken(node: HighlightNode, key: string): React.ReactNode { + if (node.type === 'text') return node.value + if (node.type !== 'element') return null + const classes = node.properties.className + return + {node.children.map((child, index) => renderToken(child, `${key}-${index}`))} + +} + +export function ChatCodeBlock({ source, language = '', dark, fadeClass = '' }: { + source: string + language?: string + dark: boolean + fadeClass?: string +}): React.JSX.Element { + useLocaleRender() + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle') + const timer = useRef | undefined>(undefined) + useEffect(() => () => clearTimeout(timer.current), []) + useEffect(() => { setCopyState('idle'); clearTimeout(timer.current) }, [source]) + const normalizedLanguage = language.toLowerCase() + const tokens = useMemo(() => { + if (source.length > 50_000 || !highlighter.registered(normalizedLanguage)) return source + try { return highlighter.highlight(normalizedLanguage, source).children.map((node, index) => renderToken(node, `${index}`)) } + catch { return source } + }, [source, normalizedLanguage]) + const copy = async (): Promise => { + try { + await navigator.clipboard.writeText(source) + setCopyState('copied') + } catch { + setCopyState('error') + } + clearTimeout(timer.current) + timer.current = setTimeout(() => setCopyState('idle'), 1800) + } + const copyLabel = copyState === 'copied' ? t('복사됨') : copyState === 'error' ? t('코드를 복사하지 못했습니다.') : t('코드 복사') + return
    +
    + {languageLabels[normalizedLanguage] ?? (language ? language.slice(0, 40) : t('일반 텍스트'))} + + {copyState === 'idle' ? '' : copyLabel} +
    +
    +      {tokens}
    +    
    +
    +} diff --git a/src/renderer/src/components/ChatWidgets.tsx b/src/renderer/src/components/ChatWidgets.tsx index 65c5a24..aa7417d 100644 --- a/src/renderer/src/components/ChatWidgets.tsx +++ b/src/renderer/src/components/ChatWidgets.tsx @@ -4,6 +4,7 @@ import type React from 'react' import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' import { CHAT_ATTACHMENT_ACCEPT, CHAT_IMAGE_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments' import { ChatImageAttachments } from './ChatImageAttachments' +import { ChatCodeBlock } from './ChatCodeBlock' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { ChatTextContextMenu, type ChatTextContextMenuState } from './ChatTextContextMenu' @@ -66,7 +67,6 @@ export function MarkdownMessage({ content, dark, fadeFrom }: { content: string; const textColor = dark ? 'text-white/76' : 'text-[#4f596b]' const headingColor = dark ? 'text-white/92' : 'text-[#111827]' const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]' - const codeBg = dark ? 'bg-[#171717] text-white/82' : 'bg-[#f1f5f9] text-[#334155]' const fadeClass = (offset?: number): string => fadeFrom !== undefined && offset !== undefined && offset >= fadeFrom ? ' agent-response-fade' : '' return ( @@ -92,30 +92,37 @@ export function MarkdownMessage({ content, dark, fadeFrom }: { content: string; {children} ), - ul: ({ children }) =>
      {children}
    , - ol: ({ children }) =>
      {children}
    , - li: ({ children, node }) =>
  1. {children}
  2. , + ul: ({ children, className }) =>
      {children}
    , + ol: ({ children, start }) =>
      {children}
    , + li: ({ children, node, className }) =>
  3. {children}
  4. , blockquote: ({ children, node }) => (
    {children}
    ), hr: ({ node }) =>
    , - pre: ({ children, node }) =>
    {children}
    , + pre: ({ children, node }) => { + const code = node?.children.find(child => child.type === 'element' && child.tagName === 'code') + if (code?.type !== 'element') return
    {children}
    + const source = code.children.map(child => child.type === 'text' ? child.value : '').join('') + const classes = code.properties.className + const language = Array.isArray(classes) ? String(classes.find(value => String(value).startsWith('language-')) ?? '').slice(9) : '' + return + }, code: ({ children, className }) => { if (className) { return {children} } - return {children} + return {children} }, table: ({ children, node }) => ( -
    -
    {children}
    +
    + {children}
    ), thead: ({ children }) => {children}, - th: ({ children }) => {children}, - td: ({ children }) => {children} + th: ({ children, style }) => {children}, + td: ({ children, style }) => {children} }} > {content} diff --git a/src/renderer/src/components/ProgressiveChatResponse.tsx b/src/renderer/src/components/ProgressiveChatResponse.tsx index 5197b96..2ad72f6 100644 --- a/src/renderer/src/components/ProgressiveChatResponse.tsx +++ b/src/renderer/src/components/ProgressiveChatResponse.tsx @@ -49,14 +49,14 @@ export function ProgressiveChatResponse({ message, dark, children, footerAction, }, [frame.duration, message.revealStartedAt, reducedMotion]) return ( -
    -
    +
    +
    {message.plan && (
    )} -
    +
    {frame.complete && frame.fadeFrom === undefined ? ( -
    +
    void onRunAgentCommand: (messageId: string, command: AgentCommand) => void onRejectAgentCommand: (messageId: string, command: AgentCommand) => void + onOpenAgentCodeReference?: (messageId: string, referenceId: string) => void + onCancelAgentCodeReference?: (messageId: string, referenceId: string) => void } function MessageCopyButton({ content, dark }: { content: string; dark: boolean }): React.JSX.Element { @@ -120,6 +128,8 @@ export default function WorkspaceQaPanel({ agentStatusLoading, agentPending, agentActivity, + agentProgressSteps = [], + agentRequestStartedAt, agentActionBusy, agentPersistenceError, aiPending, @@ -135,6 +145,8 @@ export default function WorkspaceQaPanel({ onClearConversation, onApplyAgentPlan, onCancelAgentPlan, + onOpenAgentCodeReference, + onCancelAgentCodeReference, onOpenAgentChange, onRunAgentCommand, onRejectAgentCommand @@ -197,6 +209,8 @@ export default function WorkspaceQaPanel({ } }, [ agentMessages.length, + agentProgressSteps.length, + agentActivity, agentPending, aiPending, lastMessage?.content, @@ -304,10 +318,14 @@ export default function WorkspaceQaPanel({ statusLoading={agentStatusLoading} pending={agentPending} activity={agentActivity} + progressSteps={agentProgressSteps} + requestStartedAt={agentRequestStartedAt} busy={agentPending || agentActionBusy} messages={agentMessages} onApply={onApplyAgentPlan} onCancel={onCancelAgentPlan} + onOpenCodeReference={onOpenAgentCodeReference} + onCancelCodeReference={onCancelAgentCodeReference} onOpenChange={onOpenAgentChange} onRunCommand={onRunAgentCommand} onRejectCommand={onRejectAgentCommand} @@ -361,19 +379,25 @@ function AgentMessages({ statusLoading, pending, activity, + progressSteps = [], + requestStartedAt, busy, messages, onApply, onCancel, onOpenChange, onRunCommand, - onRejectCommand + onRejectCommand, + onOpenCodeReference, + onCancelCodeReference }: { dark: boolean enabled: boolean statusLoading: boolean pending: boolean activity?: string + progressSteps?: AgentProgressStep[] + requestStartedAt?: number | null busy?: boolean messages: AgentConversationMessage[] onApply: (messageId: string, plan: AgentPlan) => void @@ -381,6 +405,8 @@ function AgentMessages({ onOpenChange: (messageId: string, change: AgentFileChange) => void onRunCommand: (messageId: string, command: AgentCommand) => void onRejectCommand: (messageId: string, command: AgentCommand) => void + onOpenCodeReference?: (messageId: string, referenceId: string) => void + onCancelCodeReference?: (messageId: string, referenceId: string) => void }): React.JSX.Element { useLocaleRender() if (statusLoading) { @@ -422,9 +448,11 @@ function AgentMessages({
    + }> {message.plan && ( <> + {!!message.plan.codeReferences?.length && onOpenCodeReference(message.id, id) : undefined} onCancel={onCancelCodeReference ? id => onCancelCodeReference(message.id, id) : undefined} />} {!!message.plan.evidence?.length && (
    {t("조회 근거")} · {message.plan.evidence.length} @@ -584,10 +612,7 @@ function AgentMessages({ {pending ? (
    -
    - {activity ?

    {activity}

    : null} - -
    +
    ) : null}
    diff --git a/src/renderer/src/features/workspace/agentCodeNavigation.ts b/src/renderer/src/features/workspace/agentCodeNavigation.ts new file mode 100644 index 0000000..9a913c2 --- /dev/null +++ b/src/renderer/src/features/workspace/agentCodeNavigation.ts @@ -0,0 +1,63 @@ +import type { editor } from 'monaco-editor' +import { safeAgentPath } from '../../../../shared/agentTools' +import type { AgentCodeReference } from './useAiAgent' + +export function validateCodeReference(reference: AgentCodeReference): void { + safeAgentPath(reference.path) + if (!Number.isSafeInteger(reference.startLine) || !Number.isSafeInteger(reference.endLine) || reference.startLine < 1 || reference.endLine < reference.startLine || reference.endLine > 100_000 || reference.endLine - reference.startLine >= 200 + || !reference.expectedHash || !/^[a-f0-9]{64}$/.test(reference.expectedHash) || !reference.snippet?.trim() || reference.snippet.length > 12_000 || reference.snippet.includes('[REDACTED]')) { + throw new Error('코드 위치의 조회 근거가 없습니다. 다시 분석을 요청해주세요.') + } +} + +export async function resolveCodeReferenceRange(content: string, reference: AgentCodeReference): Promise<{ startLine: number; endLine: number }> { + validateCodeReference(reference) + const lines = content.replace(/\r\n/g, '\n').split('\n') + const snippet = reference.snippet!.replace(/\r\n/g, '\n') + const expectedLines = reference.endLine - reference.startLine + 1 + if (snippet.split('\n').length !== expectedLines) throw new Error('코드 위치의 조회 근거가 없습니다. 다시 분석을 요청해주세요.') + const bytes = globalThis.crypto?.subtle ? await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(content)) : null + const hash = bytes ? Array.from(new Uint8Array(bytes), value => value.toString(16).padStart(2, '0')).join('') : null + if (hash === reference.expectedHash && lines.slice(reference.startLine - 1, reference.endLine).join('\n') === snippet) return { startLine: reference.startLine, endLine: reference.endLine } + + // Unsaved edits can shift lines. Only a unique, exact match is safe to reveal. + const matches: number[] = [] + for (let index = 0; index <= lines.length - expectedLines; index++) { + if (lines.slice(index, index + expectedLines).join('\n') === snippet) matches.push(index + 1) + if (matches.length > 1) break + } + if (matches.length !== 1) throw new Error('코드가 변경되어 위치를 확정할 수 없습니다. 다시 분석을 요청해주세요.') + return { startLine: matches[0], endLine: matches[0] + expectedLines - 1 } +} + +export async function revealAgentCodeReference(options: { + file: string + reference: AgentCodeReference + isCurrent: () => boolean + getEditor: () => editor.IStandaloneCodeEditor | null + getActiveFile: () => string | null + isReady: () => boolean + attempts?: number +}): Promise { + for (let attempt = 0; attempt < (options.attempts ?? 100); attempt++) { + if (!options.isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.') + const instance = options.getEditor() + const model = instance?.getModel() + const element = instance?.getDomNode() + if (options.isReady() && options.getActiveFile() === options.file && instance && element && element.clientWidth > 0 && element.clientHeight > 0 && model && !model.isDisposed() && model.uri.path === `/${options.file}`) { + const content = model.getValue() + const range = await resolveCodeReferenceRange(content, options.reference) + if (!options.isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.') + if (instance !== options.getEditor() || model !== instance.getModel() || model.getValue() !== content || !options.isReady() || options.getActiveFile() !== options.file) continue + instance.layout() + const selection = { startLineNumber: range.startLine, startColumn: 1, endLineNumber: range.endLine, endColumn: model.getLineMaxColumn(range.endLine) } + const decorations = instance.createDecorationsCollection([{ range: selection, options: { isWholeLine: true, className: 'monaco-agent-code-highlight' } }]) + instance.setPosition({ lineNumber: range.startLine, column: 1 }) + instance.revealRangeInCenter(selection) + instance.focus() + return decorations + } + await new Promise(resolve => window.setTimeout(resolve, 100)) + } + throw new Error('코드 편집기를 준비하지 못했습니다. 다시 시도해주세요.') +} diff --git a/src/renderer/src/features/workspace/agentContext.ts b/src/renderer/src/features/workspace/agentContext.ts index b8214fc..d8113f4 100644 --- a/src/renderer/src/features/workspace/agentContext.ts +++ b/src/renderer/src/features/workspace/agentContext.ts @@ -3,6 +3,30 @@ import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agent export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[] } export type AgentContextRequest = { modules: string[]; logs: string[] } +export type AgentProgressStep = { + id: string + kind: 'analysis' | 'tool' | 'files' | 'logs' | 'image' + status: 'ok' | 'error' | 'partial' | 'empty' | 'missing' + moduleName?: string + name?: string + path?: string + count?: number + round?: number + requests?: number +} + +function toolProgressStep(tool: AgentToolRequest, result: AgentToolResult, id: string): AgentProgressStep { + const step: AgentProgressStep = { id, kind: 'tool', name: tool.name, moduleName: tool.moduleName, path: tool.args?.path, status: result.status === 'error' ? 'error' : result.truncated ? 'partial' : 'ok' } + if (result.status !== 'ok') return step + try { + const data = JSON.parse(result.data) + if (tool.name === 'files.read' && data?.exists === false) step.status = 'missing' + else if (tool.name === 'files.read' && data?.complete === false) step.status = 'partial' + const items = tool.name === 'files.list' ? data?.paths : tool.name === 'files.search' ? data?.matches : undefined + if (Array.isArray(items)) step.count = items.length + } catch { /* Progress never exposes raw tool output or treats it as instructions. */ } + return step +} export type AgentPlanResponse = { provider: string model: string @@ -14,10 +38,11 @@ export function recoverInterruptedAgentMessages(messages: AgentConversationMessa let changed = false const recovered = messages.map(message => { const interrupted = Object.entries(message.commandExecutions ?? {}).some(([, execution]) => execution.status === 'running') - if (!interrupted && message.applyState !== 'applying') return message + const interruptedNavigation = Object.values(message.codeReferenceStates ?? {}).some(state => state.status === 'opening') + if (!interrupted && !interruptedNavigation && message.applyState !== 'applying') return message changed = true const error = '이전 작업의 완료 결과를 확인할 수 없습니다. 현재 모듈·파일 상태를 다시 조회한 뒤 진행해주세요.' - return { ...message, ...(message.applyState === 'applying' ? { applyState: 'error' as const, applyError: error } : {}), commandExecutions: Object.fromEntries(Object.entries(message.commandExecutions ?? {}).map(([id, execution]) => [id, execution.status === 'running' ? { ...execution, status: 'failed' as const, error } : execution])) } + return { ...message, ...(message.applyState === 'applying' ? { applyState: 'error' as const, applyError: error } : {}), ...(interruptedNavigation ? { codeReferenceStates: Object.fromEntries(Object.entries(message.codeReferenceStates ?? {}).map(([id, state]) => [id, state.status === 'opening' ? { status: 'error' as const, error: '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' } : state])) } : {}), commandExecutions: Object.fromEntries(Object.entries(message.commandExecutions ?? {}).map(([id, execution]) => [id, execution.status === 'running' ? { ...execution, status: 'failed' as const, error } : execution])) } }) return changed ? recovered : messages } @@ -100,6 +125,7 @@ export async function resolveAgentContext({ loadRequestedContext, executeTool, onProgress, + onStep, signal }: { initialContext: AgentContext @@ -107,6 +133,7 @@ export async function resolveAgentContext({ loadRequestedContext?: (request: AgentContextRequest) => Promise executeTool?: (request: AgentToolRequest) => Promise onProgress?: (label: string) => void + onStep?: (step: AgentProgressStep) => void signal: AbortSignal }): Promise { let cachedContext = initialContext @@ -127,6 +154,7 @@ export async function resolveAgentContext({ } const finalize = async (): Promise => { checkAborted() + onProgress?.('최종 답변 정리 중') const response = await requestPlan(context, true) checkAborted() addUsage(response) @@ -154,12 +182,14 @@ export async function resolveAgentContext({ const maxRounds = executeTool ? 8 : 5 for (let round = 0; round <= maxRounds; round += 1) { checkAborted() + onProgress?.(round === 0 ? '요청 분석 중' : '조회 결과 분석 중') const response = await requestPlan(context) checkAborted() addUsage(response) const modules = [...new Set(response.plan.requestedModules ?? [])] const logs = [...new Set(response.plan.requestedLogs ?? [])] const tools = response.plan.toolRequests ?? [] + onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length }) if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } } if (round === maxRounds) return finalize() if (tools.length) { @@ -175,6 +205,7 @@ export async function resolveAgentContext({ onProgress?.(`${tool.moduleName ? `${tool.moduleName} · ` : ''}${tool.name} 조회 중`) const result = await executeTool({ ...tool, id: `query-${round}-${index}-${tool.id}`.slice(0, 80) }) checkAborted() + onStep?.(toolProgressStep(tool, result, `tool-${round}-${index}`)) toolCount++ results.push(result) } @@ -195,8 +226,19 @@ export async function resolveAgentContext({ } nextRequest.modules.forEach((name) => requestedModules.add(name)) nextRequest.logs.forEach((name) => requestedLogs.add(name)) + onProgress?.('추가 자료 조회 중') const loaded = await loadRequestedContext(nextRequest) checkAborted() + for (const name of nextRequest.modules) { + const module = loaded.modules.find(module => module.name === name) + const state = module?.fileContext?.status + const status = state === 'error' ? 'error' : state === 'uninstalled' ? 'missing' : state === 'limited' ? 'partial' : module?.files.length ? 'ok' : 'empty' + onStep?.({ id: `files-${round}-${name}`, kind: 'files', moduleName: name, status, count: module?.files.length ?? 0 }) + } + for (const name of nextRequest.logs) { + const log = loaded.runtimeLogs.find(log => log.moduleName === name) + onStep?.({ id: `logs-${round}-${name}`, kind: 'logs', moduleName: name, status: log?.content ? 'ok' : 'empty' }) + } const loadedModules = new Map(loaded.modules.map((module) => [module.name, module])) const mergedModules = cachedContext.modules.map((module) => { const updated = loadedModules.get(module.name) diff --git a/src/renderer/src/features/workspace/useAiAgent.ts b/src/renderer/src/features/workspace/useAiAgent.ts index 3fcbcec..4f83766 100644 --- a/src/renderer/src/features/workspace/useAiAgent.ts +++ b/src/renderer/src/features/workspace/useAiAgent.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { createAgentSessionSync } from './agentSessionSync' -import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext' +import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext' import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments' import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools' @@ -12,6 +12,22 @@ export type AgentFileChange = { reason: string } +export type AgentCodeReference = { + id: string + moduleName: string + path: string + startLine: number + endLine: number + reason: string + expectedHash?: string + snippet?: string +} + +export type AgentCodeReferenceState = { + status: 'pending' | 'opening' | 'opened' | 'cancelled' | 'error' + error?: string +} + export type AgentModulePlan = { name: string category: string @@ -37,6 +53,7 @@ export type AgentCommandExecution = { } export type AgentPlan = { + codeReferences?: AgentCodeReference[] toolRequests?: AgentToolRequest[] evidence?: AgentToolResult[] summary: string @@ -76,11 +93,13 @@ export type AgentConversationMessage = { provider?: string model?: string revealStartedAt?: number + durationMs?: number imageAttachments?: AgentImageReference[] plan?: AgentPlan applyState?: 'ready' | 'applying' | 'applied' | 'error' | 'cancelled' applyError?: string commandExecutions?: Record + codeReferenceStates?: Record usage?: { promptTokens?: number completionTokens?: number @@ -119,9 +138,12 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap const [historyError, setHistoryError] = useState(null) const [pending, setPending] = useState(false) const [activity, setActivity] = useState('') + const [progressSteps, setProgressSteps] = useState([]) + const [requestStartedAt, setRequestStartedAt] = useState(null) const [messages, setMessages] = useState([]) const abortControllerRef = useRef(null) const applyingRef = useRef(false) + const navigatingRef = useRef(false) const tokenReaderRef = useRef(getFreshAccessToken) tokenReaderRef.current = getFreshAccessToken const onResponseRef = useRef(onResponse) @@ -209,6 +231,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap // Request cancellation follows the conversation lifetime, not its visibility. useEffect(() => { setPending(false) + setRequestStartedAt(null) + setProgressSteps([]) + setActivity('') return () => { abortControllerRef.current?.abort() abortControllerRef.current = null @@ -273,6 +298,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap ): Promise => { const request = rawRequest.trim() if (!request || pending || historyLoading || abortControllerRef.current || !status.enabled) return + const startedAt = Date.now() const userMessage: AgentConversationMessage = { id: `agent-user-${Date.now()}`, role: 'user', @@ -280,9 +306,15 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap } if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage]) setPending(true) + setRequestStartedAt(startedAt) + setProgressSteps([]) setActivity('요청 분석 중') const abortController = new AbortController() abortControllerRef.current = abortController + const currentRequest = (): boolean => !abortController.signal.aborted && abortControllerRef.current === abortController && messageStateRef.current.session === session + const appendStep = (step: AgentProgressStep): void => { + if (currentRequest()) setProgressSteps(current => [...current, step]) + } try { const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }]) @@ -299,6 +331,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap 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 + appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name }) } const imageTokens = imageAttachments.map(image => image.token) setActivity('요청 분석 중') @@ -307,7 +340,8 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap signal: abortController.signal, loadRequestedContext: options.loadRequestedContext, executeTool: options.executeTool, - onProgress: setActivity, + onProgress: label => { if (currentRequest()) setActivity(label) }, + onStep: appendStep, requestPlan: (context, finalize) => requestApi('/chat/agent/plan', { method: 'POST', signal: abortController.signal, @@ -327,6 +361,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap id: `agent-plan-${Date.now()}`, role: 'assistant', revealStartedAt: Date.now(), + durationMs: Math.max(0, Date.now() - startedAt), content: plan.summary, provider: response.provider, model: response.model, @@ -342,6 +377,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap id: `agent-error-${Date.now()}`, role: 'assistant', revealStartedAt: Date.now(), + durationMs: Math.max(0, Date.now() - startedAt), content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.' }]) onResponseRef.current?.() @@ -349,7 +385,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap if (abortControllerRef.current === abortController) { abortControllerRef.current = null setPending(false) + setRequestStartedAt(null) setActivity('') + setProgressSteps([]) } } } @@ -404,6 +442,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap abortControllerRef.current?.abort() abortControllerRef.current = null setPending(false) + setRequestStartedAt(null) + setActivity('') + setProgressSteps([]) } const updateCommandExecution = (messageId: string, commandId: string, execution: AgentCommandExecution): void => { @@ -418,6 +459,34 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap : message)) } + const cancelCodeReference = (messageId: string, referenceId: string): void => { + updateMessages(current => current.map(message => message.id === messageId && message.plan?.codeReferences?.some(reference => reference.id === referenceId) && message.codeReferenceStates?.[referenceId]?.status !== 'opening' + ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: { status: 'cancelled' } } } + : message)) + } + + const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise): Promise => { + if (navigatingRef.current || applyingRef.current || abortControllerRef.current || messageStateRef.current.session !== session) return + const message = messageStateRef.current.messages.find(message => message.id === messageId) + const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId) + if (!reference || message?.codeReferenceStates?.[referenceId]?.status === 'cancelled') return + const latestMessageId = messageStateRef.current.messages.at(-1)?.id + const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId) + const update = (state: AgentCodeReferenceState): void => { + if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message)) + } + navigatingRef.current = true + update({ status: 'opening' }) + try { + await open(reference, () => isCurrent() && messageStateRef.current.messages.at(-1)?.id === latestMessageId && !abortControllerRef.current) + update({ status: 'opened' }) + } catch (error) { + update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' }) + } finally { + navigatingRef.current = false + } + } + const clear = (): void => { if (!authenticated || historyLoading) return stop() @@ -444,12 +513,16 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap statusLoading: statusLoading || historyLoading, pending, activity, + progressSteps, + requestStartedAt, messages, persistenceError: persistenceError ?? historyError, requestPlan, applyPlan, cancelPlan, updateCommandExecution, + openCodeReference, + cancelCodeReference, stop, clear } diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 4038f6b..827c683 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -719,5 +719,43 @@ "일부 결과": "Partial results", "SLAM 실습 준비": "Prepare SLAM practice", "승인하고 준비": "Approve and prepare", - "내 PC에서 직접 개발 환경을 실행합니다.": "Run the development environment directly on this PC." + "내 PC에서 직접 개발 환경을 실행합니다.": "Run the development environment directly on this PC.", + "진행 상황": "Progress", + "{{seconds}}초 동안 작업 중입니다": "Working for {{seconds}} seconds", + "{{seconds}}초 동안 작업함": "Worked for {{seconds}} seconds", + "모듈 목록": "Module list", + "{{round}}차 분석 완료": "Analysis round {{round}} completed", + "추가 조회 {{count}}건": "{{count}} additional lookups requested", + "이미지 업로드 완료": "Image uploaded", + "파일 목록": "File list", + "파일 검색": "File search", + "파일 읽기": "File read", + "로그 조회": "Log lookup", + "네트워크 조회": "Network inspection", + "ROS 상태 조회": "ROS inspection", + "실습 구성 조회": "Practice configuration", + "조회 결과 없음": "No results", + "대상 없음": "Not found", + "소스 파일": "Source files", + "{{count}}건": "{{count}} items", + "요청 분석 중": "Analyzing request", + "조회 결과 분석 중": "Analyzing retrieved results", + "추가 자료 조회 중": "Retrieving additional context", + "최종 답변 정리 중": "Preparing the final answer", + "코드에서 보기": "View in editor", + "파일 여는 중...": "Opening file...", + "{{start}}~{{end}}줄": "Lines {{start}}-{{end}}", + "코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.": "Could not open the code location. Ask the agent to analyze it again.", + "코드 위치의 조회 근거가 없습니다. 다시 분석을 요청해주세요.": "The code location has no verified source. Ask the agent to analyze it again.", + "코드가 변경되어 위치를 확정할 수 없습니다. 다시 분석을 요청해주세요.": "The code has changed and its location is no longer certain. Ask the agent to analyze it again.", + "코드 위치 열기가 중단되었습니다.": "Opening the code location was cancelled.", + "코드 편집기를 준비하지 못했습니다. 다시 시도해주세요.": "The editor could not be prepared. Please try again.", + "진행 중인 에이전트 작업이 끝난 뒤 열어주세요.": "Wait for the current agent task before opening the file.", + "코드 위치의 모듈을 찾을 수 없습니다. 다시 분석을 요청해주세요.": "The referenced module was not found. Ask the agent to analyze it again.", + "코드 파일을 읽지 못했습니다. 다시 분석을 요청해주세요.": "The code file could not be read. Ask the agent to analyze it again.", + "코드 복사": "Copy code", + "코드를 복사하지 못했습니다.": "Could not copy the code.", + "일반 텍스트": "Plain text", + "코드": "Code", + "표": "Table" } diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index b8fef92..f901eed 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -1,36 +1,11 @@ @import "tailwindcss"; @import "driver.js/dist/driver.css"; +@import "./styles/nanumSquare.css"; +@import "./styles/chatCode.css"; -@font-face { - font-family: 'NanumSquare'; - src: url('/assets/fonts/NanumSquareL.woff2') format('woff2'); - font-weight: 300; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'NanumSquare'; - src: url('/assets/fonts/NanumSquare.woff2') format('woff2'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'NanumSquare'; - src: url('/assets/fonts/NanumSquareB.woff2') format('woff2'); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'NanumSquare'; - src: url('/assets/fonts/NanumSquareEB.woff2') format('woff2'); - font-weight: 800; - font-style: normal; - font-display: swap; +.monaco-agent-code-highlight { + background: rgba(124, 58, 237, 0.16); + border-left: 2px solid #8b5cf6; } @layer base { diff --git a/src/renderer/src/styles/chatCode.css b/src/renderer/src/styles/chatCode.css new file mode 100644 index 0000000..937743c --- /dev/null +++ b/src/renderer/src/styles/chatCode.css @@ -0,0 +1,48 @@ +.chat-code-block[data-code-theme='dark'] { + --code-keyword: #c5a5ee; + --code-string: #b5d69b; + --code-number: #efbc82; + --code-title: #8bc7ed; + --code-comment: #939da8; + --code-meta: #e9d38c; + --code-attribute: #92d6cc; +} + +.chat-code-block[data-code-theme='light'] { + --code-keyword: #833ba3; + --code-string: #247041; + --code-number: #a34d11; + --code-title: #1765a4; + --code-comment: #64748b; + --code-meta: #896418; + --code-attribute: #14766e; +} + +.chat-code-block :is(.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-doctag) { + color: var(--code-keyword); +} + +.chat-code-block :is(.hljs-string, .hljs-regexp, .hljs-addition) { + color: var(--code-string); +} + +.chat-code-block :is(.hljs-number, .hljs-symbol, .hljs-bullet) { + color: var(--code-number); +} + +.chat-code-block :is(.hljs-title, .hljs-built_in, .hljs-section, .hljs-selector-class) { + color: var(--code-title); +} + +.chat-code-block :is(.hljs-comment, .hljs-quote) { + color: var(--code-comment); + font-style: italic; +} + +.chat-code-block :is(.hljs-meta, .hljs-deletion) { + color: var(--code-meta); +} + +.chat-code-block :is(.hljs-attr, .hljs-attribute, .hljs-variable, .hljs-type) { + color: var(--code-attribute); +} diff --git a/src/renderer/src/styles/nanumSquare.css b/src/renderer/src/styles/nanumSquare.css new file mode 100644 index 0000000..b82adb5 --- /dev/null +++ b/src/renderer/src/styles/nanumSquare.css @@ -0,0 +1,776 @@ +/* Generated by scripts/generate-nanumsquare-css.py. Do not edit manually. + * Empty glyphs are excluded so the existing system-font fallback can render them. + * Font binaries and visible glyphs are unchanged. + */ + +/* NanumSquareL.woff2 sha256: 310d868fb3afa240a7e8190c83f6eb29ef3ddd9764c3bdf62bfd15e7007f6c36 */ +@font-face { + font-family: 'NanumSquare'; + src: url('/assets/fonts/NanumSquareL.woff2') format('woff2'); + font-weight: 300; + font-style: normal; + font-display: swap; + unicode-range: + U+20-7E, U+A1, U+A4, U+A7-AB, U+B0-B4, U+B6, U+B8-BF, U+C6, U+D0, U+D7-D8, + U+DE-DF, U+E6, U+F0, U+F7-F8, U+FE, U+111, U+126-127, U+131-133, U+138, U+13F-142, + U+149-14B, U+152-153, U+166-167, U+2C7, U+2D8-2DD, U+391-3A1, U+3A3-3A9, U+3B1-3C1, U+3C3-3C9, U+401, + U+410-44F, U+451, U+2010, U+2013-2014, U+2016, U+2018-2019, U+201C-201D, U+2020-2021, U+2025-2026, U+2030, + U+2032-2033, U+203B, U+2074, U+207F, U+2081-2084, U+20A9, U+2103, U+2109, U+2113, U+2116, + U+2121-2122, U+2126, U+212B, U+2153-2154, U+215B-215E, U+2160-2169, U+2170-2179, U+2190-2199, U+21D2, U+21D4, + U+2200, U+2202-2203, U+2207-2208, U+220B, U+220F, U+2211, U+221A, U+221D-221E, U+2220, U+2227-222C, + U+222E, U+2234-2236, U+223D, U+2252, U+2260-2261, U+2264-2265, U+2282-2283, U+2286-2287, U+22A5, U+22EF, + U+2312, U+2460-246E, U+2474-2482, U+249C-24B5, U+24D0-24E9, U+2500-2503, U+250C-254B, U+2592, U+25A0-25A1, U+25A3-25A9, + U+25B2-25B3, U+25B6-25B7, U+25BC-25BD, U+25C0-25C1, U+25C6-25C9, U+25CB, U+25CE-25D1, U+2605-2606, U+260E-260F, U+261C, + U+261E, U+2640, U+2642, U+2660-2661, U+2663-2665, U+2667-266A, U+266C-266D, U+3000-3003, U+3008-3011, U+3013-3015, + U+3041-3093, U+30A1-30F6, U+30FB, U+3131-318E, U+3200-321C, U+3260-327B, U+327F, U+3380-3384, U+3388-33CA, U+33CF-33D0, + U+33D3, U+33D6, U+33D8, U+33DB-33DD, U+AC00-AC01, U+AC04, U+AC07-AC0A, U+AC10-AC17, U+AC19-AC1D, U+AC20, + U+AC24, U+AC2C-AC2D, U+AC2F-AC31, U+AC38-AC39, U+AC3C, U+AC40, U+AC4B, U+AC4D, U+AC54, U+AC58, + U+AC5C, U+AC70-AC71, U+AC74, U+AC77-AC78, U+AC7A, U+AC80-AC81, U+AC83-AC86, U+AC89-AC8C, U+AC90, U+AC94, + U+AC9C-AC9D, U+AC9F-ACA1, U+ACA8-ACAA, U+ACAC, U+ACAF-ACB0, U+ACB8-ACB9, U+ACBB-ACBD, U+ACC1, U+ACC4, U+ACC8, + U+ACCC, U+ACD5, U+ACD7, U+ACE0-ACE1, U+ACE4, U+ACE7-ACE8, U+ACEA, U+ACEC, U+ACEF-ACF1, U+ACF3, + U+ACF5-ACF6, U+ACFC-ACFD, U+AD00, U+AD04, U+AD06, U+AD0C-AD0D, U+AD0F, U+AD11, U+AD18, U+AD1C, + U+AD1E, U+AD20, U+AD29, U+AD2C-AD2D, U+AD34-AD35, U+AD38, U+AD3C, U+AD44-AD45, U+AD47, U+AD49, + U+AD50, U+AD54, U+AD58, U+AD61, U+AD63, U+AD65, U+AD6C-AD6D, U+AD70, U+AD73-AD76, U+AD7B-AD7D, + U+AD7F, U+AD81-AD82, U+AD88-AD89, U+AD8C, U+AD90, U+AD9C-AD9D, U+ADA4, U+ADB4, U+ADB7, U+ADC0-ADC1, + U+ADC4, U+ADC8, U+ADD0-ADD1, U+ADD3, U+ADDC, U+ADE0, U+ADE4, U+ADF8-ADF9, U+ADFC, U+ADFF-AE01, + U+AE08-AE09, U+AE0B, U+AE0D, U+AE14, U+AE1C, U+AE30-AE31, U+AE34, U+AE37-AE38, U+AE3A, U+AE40-AE41, + U+AE43, U+AE45-AE46, U+AE4A, U+AE4C-AE4E, U+AE50, U+AE54, U+AE56, U+AE5C-AE5D, U+AE5F-AE61, U+AE65, + U+AE68-AE69, U+AE6C, U+AE70, U+AE78-AE79, U+AE7B-AE7D, U+AE84-AE86, U+AE8C-AE8D, U+AEA0, U+AEBC-AEBE, U+AEC0, + U+AEC4, U+AECC-AECD, U+AECF-AED1, U+AED8-AED9, U+AEDC, U+AEE8, U+AEEB, U+AEED, U+AEF4, U+AEF8, + U+AEFC, U+AF07-AF08, U+AF0D, U+AF10, U+AF2C-AF2D, U+AF30, U+AF32, U+AF34, U+AF3C-AF3D, U+AF3F, + U+AF41-AF43, U+AF48-AF49, U+AF50, U+AF5C-AF5D, U+AF64-AF65, U+AF79, U+AF80, U+AF84, U+AF88, U+AF90-AF91, + U+AF95, U+AF9C, U+AFA4, U+AFB8-AFBA, U+AFBC, U+AFC0, U+AFC7-AFC9, U+AFCB, U+AFCD-AFCE, U+AFD4, + U+AFDC, U+AFE8-AFE9, U+AFF0-AFF1, U+AFF4, U+AFF8-AFF9, U+AFFF-B001, U+B004, U+B00C, U+B010, U+B014, + U+B01C-B01D, U+B028, U+B044-B045, U+B048, U+B04A, U+B04C, U+B04E, U+B053-B055, U+B057, U+B059, + U+B05D, U+B068, U+B07C-B07D, U+B080, U+B084, U+B08C-B08D, U+B08F, U+B091, U+B098-B09A, U+B09C, + U+B09F-B0A2, U+B0A8-B0A9, U+B0AB-B0AF, U+B0B1, U+B0B3-B0B5, U+B0B8, U+B0BC, U+B0C4-B0C5, U+B0C7-B0C9, U+B0D0-B0D1, + U+B0D4, U+B0D8, U+B0E0, U+B0E5, U+B108-B109, U+B10B-B10C, U+B110, U+B112-B113, U+B118-B119, U+B11B-B11D, + U+B123-B125, U+B128, U+B12C, U+B134-B135, U+B137-B139, U+B140-B141, U+B144, U+B148, U+B150-B151, U+B154-B155, + U+B158, U+B15C, U+B160, U+B178-B179, U+B17C, U+B180, U+B182, U+B188-B189, U+B18B, U+B18D, + U+B192-B194, U+B198, U+B19C, U+B1A8, U+B1CC, U+B1D0, U+B1D4, U+B1DC-B1DD, U+B1DF, U+B1E8-B1E9, + U+B1EC, U+B1F0, U+B1F9, U+B1FB, U+B1FD, U+B204-B205, U+B208, U+B20B-B20C, U+B214-B215, U+B217, + U+B219, U+B220, U+B234, U+B23C, U+B24C-B24D, U+B251, U+B258, U+B25C, U+B260, U+B268-B269, + U+B274-B275, U+B27C, U+B284-B285, U+B289, U+B290-B291, U+B294, U+B298-B29A, U+B2A0-B2A1, U+B2A3, U+B2A5-B2A6, + U+B2AA, U+B2AC, U+B2B0, U+B2B4, U+B2C8-B2C9, U+B2CC, U+B2D0, U+B2D2, U+B2D8-B2D9, U+B2DB, + U+B2DD, U+B2E2, U+B2E4-B2E6, U+B2E8, U+B2EB-B2EF, U+B2F3-B2F5, U+B2F7-B2FB, U+B2FF-B301, U+B304, U+B308, + U+B310-B311, U+B313-B315, U+B31C, U+B324, U+B32C, U+B331, U+B354-B356, U+B358, U+B35B-B35C, U+B35E-B35F, + U+B364-B365, U+B367, U+B369, U+B36B, U+B36E, U+B370-B371, U+B374, U+B378, U+B380-B381, U+B383-B385, + U+B38C, U+B390, U+B394, U+B3A0-B3A1, U+B3A8, U+B3AC, U+B3C4-B3C5, U+B3C8, U+B3CB-B3CC, U+B3CE, + U+B3D0, U+B3D4-B3D5, U+B3D7, U+B3D9, U+B3DB, U+B3DD, U+B3E0, U+B3E4, U+B3E8, U+B3FC, + U+B400, U+B40F-B411, U+B418, U+B41C, U+B420, U+B428-B429, U+B42B-B42D, U+B434, U+B450-B451, U+B454, + U+B458, U+B460-B461, U+B463, U+B465, U+B46C, U+B480, U+B488, U+B49D, U+B4A4, U+B4A8, + U+B4AC, U+B4B5, U+B4B7, U+B4B9, U+B4C0, U+B4C4, U+B4C8, U+B4D0, U+B4D5, U+B4DC-B4DD, + U+B4E0, U+B4E3-B4E4, U+B4E6, U+B4EC-B4ED, U+B4EF, U+B4F1, U+B4F8, U+B514-B515, U+B518, U+B51B-B51C, + U+B524-B525, U+B527-B52A, U+B530-B531, U+B534, U+B538, U+B540-B541, U+B543-B545, U+B54B-B54D, U+B550, U+B554, + U+B55C-B55D, U+B55F-B561, U+B568, U+B584, U+B599, U+B5A0-B5A1, U+B5A4, U+B5A8, U+B5AA-B5AB, U+B5B0-B5B1, + U+B5B3-B5B5, U+B5BB-B5BD, U+B5C0, U+B5C4, U+B5CC-B5CD, U+B5CF-B5D1, U+B5D8, U+B5EC, U+B610-B611, U+B614, + U+B618, U+B620, U+B625, U+B62C, U+B634, U+B648, U+B65D, U+B664, U+B668, U+B69C-B69D, + U+B6A0, U+B6A4, U+B6AB-B6AC, U+B6B1, U+B6D4, U+B6F0, U+B6F4, U+B6F8, U+B700-B701, U+B705, + U+B71B, U+B728-B729, U+B72C, U+B72F-B730, U+B738-B739, U+B73B, U+B73D, U+B744, U+B748, U+B74C, + U+B754-B755, U+B760-B761, U+B764, U+B768, U+B770-B771, U+B773, U+B775, U+B77C-B77D, U+B780, U+B784, + U+B78C-B78D, U+B78F-B792, U+B796-B799, U+B79C, U+B7A0, U+B7A8-B7A9, U+B7AB-B7AD, U+B7B4-B7B5, U+B7B8, U+B7BC, + U+B7C7, U+B7C9, U+B7EC-B7ED, U+B7F0, U+B7F4, U+B7FC-B7FD, U+B7FF-B801, U+B807-B809, U+B80C, U+B810, + U+B818-B819, U+B81B, U+B81D, U+B824-B825, U+B828, U+B82C, U+B834-B835, U+B837-B839, U+B840, U+B844, + U+B851, U+B853, U+B85C-B85D, U+B860, U+B864, U+B86C-B86D, U+B86F, U+B871, U+B878-B879, U+B87C, + U+B880, U+B88D, U+B894, U+B8A8, U+B8B0, U+B8B4, U+B8B8, U+B8C0-B8C1, U+B8C3, U+B8C5, + U+B8CC, U+B8D0, U+B8D4, U+B8DD, U+B8DF, U+B8E1, U+B8E8-B8E9, U+B8EC, U+B8F0, U+B8F8-B8F9, + U+B8FB, U+B8FD, U+B904, U+B918, U+B920, U+B93C-B93D, U+B940, U+B944, U+B94C, U+B94F, + U+B951, U+B958-B959, U+B95C, U+B960, U+B968-B969, U+B96B, U+B96D, U+B974-B975, U+B978, U+B97C, + U+B984-B985, U+B987, U+B989-B98A, U+B98D-B98E, U+B9AC-B9AD, U+B9B0, U+B9B4, U+B9BC-B9BD, U+B9BF, U+B9C1, + U+B9C8-B9C9, U+B9CC, U+B9CE-B9D2, U+B9D8-B9D9, U+B9DB, U+B9DD-B9DE, U+B9E1, U+B9E3-B9E5, U+B9E8, U+B9EC, + U+B9F4-B9F5, U+B9F7-B9FA, U+BA00-BA01, U+BA04, U+BA08, U+BA15, U+BA38-BA39, U+BA3C, U+BA40, U+BA42, + U+BA48-BA49, U+BA4B, U+BA4D-BA4E, U+BA53-BA55, U+BA58, U+BA5C, U+BA64-BA65, U+BA67-BA69, U+BA70-BA71, U+BA74, + U+BA78, U+BA83-BA85, U+BA87, U+BA8C, U+BAA8-BAA9, U+BAAB-BAAC, U+BAB0, U+BAB2, U+BAB8-BAB9, U+BABB, + U+BABD, U+BAC4, U+BAC8, U+BAD8-BAD9, U+BAFC, U+BB00, U+BB04, U+BB0D, U+BB0F, U+BB11, + U+BB18, U+BB1C, U+BB20, U+BB29, U+BB2B, U+BB34-BB36, U+BB38, U+BB3B-BB3E, U+BB44-BB45, U+BB47, + U+BB49, U+BB4D, U+BB4F-BB50, U+BB54, U+BB58, U+BB61, U+BB63, U+BB65, U+BB6C, U+BB88, + U+BB8C, U+BB90, U+BBA4, U+BBA8, U+BBAC, U+BBB4, U+BBB7, U+BBC0, U+BBC4, U+BBC8, + U+BBD0, U+BBD3, U+BBDC, U+BBF8-BBF9, U+BBFC, U+BBFF-BC00, U+BC02, U+BC08-BC09, U+BC0B-BC0D, U+BC0F, + U+BC11, U+BC14-BC18, U+BC1B-BC1F, U+BC24-BC25, U+BC27, U+BC29, U+BC2D, U+BC30-BC31, U+BC34, U+BC38, + U+BC40-BC41, U+BC43-BC45, U+BC49, U+BC4C-BC4D, U+BC50, U+BC5D, U+BC84-BC85, U+BC88, U+BC8B-BC8C, U+BC8E, + U+BC94-BC95, U+BC97, U+BC99-BC9A, U+BCA0-BCA1, U+BCA4, U+BCA7-BCA9, U+BCB0-BCB1, U+BCB3-BCB5, U+BCBC-BCBD, U+BCC0, + U+BCC4, U+BCCD, U+BCCF-BCD1, U+BCD5, U+BCD8, U+BCDC, U+BCF4-BCF6, U+BCF8, U+BCFC, U+BD04-BD05, + U+BD07, U+BD09, U+BD10, U+BD14, U+BD23-BD25, U+BD2C, U+BD35, U+BD40, U+BD48-BD49, U+BD4C, + U+BD50, U+BD58-BD59, U+BD64, U+BD68, U+BD79, U+BD80-BD81, U+BD84, U+BD87-BD8A, U+BD90-BD91, U+BD93, + U+BD95, U+BD99-BD9A, U+BD9C, U+BDA4, U+BDB0, U+BDB8-BDB9, U+BDC1, U+BDD4-BDD5, U+BDD8, U+BDDC, + U+BDE9, U+BDF0, U+BDF4, U+BDF8, U+BE00, U+BE03, U+BE05, U+BE0C-BE0D, U+BE10, U+BE14, + U+BE1C-BE1D, U+BE1F, U+BE44-BE45, U+BE48, U+BE4C, U+BE4E, U+BE54-BE55, U+BE57, U+BE59-BE5B, U+BE60-BE61, + U+BE64, U+BE68, U+BE6A, U+BE70-BE71, U+BE73-BE75, U+BE7B-BE7D, U+BE80, U+BE84, U+BE8C-BE8D, U+BE8F-BE91, + U+BE98-BE99, U+BEA8, U+BEAD, U+BEB4, U+BED0-BED1, U+BED4, U+BED7-BED8, U+BEE0, U+BEE3-BEE5, U+BEEC, + U+BEF5, U+BF01, U+BF08-BF09, U+BF18-BF19, U+BF1B-BF1D, U+BF40-BF41, U+BF44, U+BF48, U+BF50-BF51, U+BF55, + U+BF5C, U+BF71, U+BF94, U+BFB0, U+BFB8, U+BFC5, U+BFCC-BFCD, U+BFD0, U+BFD4, U+BFDC, + U+BFDF, U+BFE1, U+C00D, U+C03C, U+C04C, U+C051, U+C058, U+C05C, U+C060, U+C068-C069, + U+C090-C091, U+C094, U+C098, U+C0A0-C0A1, U+C0A3, U+C0A5, U+C0AC-C0AD, U+C0AF-C0B0, U+C0B3-C0B6, U+C0BC-C0BD, + U+C0BF-C0C1, U+C0C5, U+C0C8-C0C9, U+C0CC, U+C0D0, U+C0D8-C0D9, U+C0DB-C0DD, U+C0E4-C0E5, U+C0E8, U+C0EC, + U+C0F4-C0F5, U+C0F7, U+C0F9, U+C0FE, U+C100, U+C104, U+C108, U+C110, U+C115, U+C11C-C120, + U+C123-C124, U+C126-C127, U+C12C-C12D, U+C12F-C131, U+C136, U+C138-C139, U+C13C, U+C140, U+C142, U+C148-C149, + U+C14B-C14D, U+C154-C155, U+C158, U+C15C, U+C164-C165, U+C167-C169, U+C170, U+C174, U+C178, U+C185, + U+C18C-C18E, U+C190, U+C194, U+C196, U+C19C-C19D, U+C19F, U+C1A1, U+C1A5, U+C1A8-C1A9, U+C1AC, + U+C1B0, U+C1BD, U+C1C4, U+C1C8, U+C1CC, U+C1D4, U+C1D7-C1D8, U+C1E0, U+C1E4, U+C1E8, + U+C1F0-C1F1, U+C1F3, U+C1FC-C1FD, U+C200, U+C204, U+C20C-C20D, U+C20F, U+C211, U+C216, U+C218-C219, + U+C21C, U+C21F-C220, U+C228-C229, U+C22B, U+C22D, U+C22F, U+C231-C232, U+C234, U+C245, U+C248, + U+C250-C251, U+C254, U+C258, U+C25F-C260, U+C265, U+C26C-C26D, U+C270, U+C274, U+C27C-C27D, U+C27F, + U+C281, U+C288-C289, U+C28C, U+C290, U+C298-C299, U+C29B, U+C29D, U+C2A0, U+C2A4-C2A5, U+C2A8, + U+C2AC-C2AD, U+C2B4-C2B5, U+C2B7, U+C2B9, U+C2DC-C2DD, U+C2E0, U+C2E3-C2E4, U+C2EB-C2ED, U+C2EF, U+C2F1, + U+C2F6, U+C2F8-C2F9, U+C2FB-C2FC, U+C300, U+C308-C309, U+C30C-C30D, U+C313-C315, U+C318, U+C31C, U+C324-C325, + U+C328-C329, U+C330, U+C338, U+C345, U+C34C, U+C368-C369, U+C36C, U+C370, U+C372, U+C378-C379, + U+C37B-C37D, U+C384, U+C388, U+C38C, U+C395, U+C399, U+C3B5, U+C3BC, U+C3C0, U+C3D8-C3D9, + U+C3DC, U+C3DF-C3E0, U+C3E2, U+C3E8-C3E9, U+C3ED, U+C3F4-C3F5, U+C3F8, U+C3FC, U+C408, U+C410, + U+C424, U+C42C, U+C430, U+C434, U+C43C-C43D, U+C448, U+C450, U+C45D, U+C464-C465, U+C468, + U+C46C, U+C474-C475, U+C479, U+C480, U+C494, U+C49C-C49D, U+C4B8, U+C4BC, U+C4CD, U+C4D4, + U+C4D8, U+C4E9, U+C4F0-C4F1, U+C4F4, U+C4F8, U+C4FA, U+C4FF-C501, U+C50C, U+C510, U+C514, + U+C51C, U+C521, U+C528-C529, U+C52C, U+C530, U+C538-C539, U+C53B, U+C53D, U+C544-C545, U+C548-C54A, + U+C54C-C54E, U+C553-C555, U+C557-C559, U+C55C-C55E, U+C560-C561, U+C564, U+C568, U+C570-C571, U+C573-C575, U+C57B-C57D, + U+C580, U+C584, U+C587, U+C58C-C58D, U+C58F, U+C591, U+C595, U+C597-C598, U+C59C, U+C5A0, + U+C5A9, U+C5B4-C5B5, U+C5B8-C5B9, U+C5BB-C5BE, U+C5C4-C5CA, U+C5CC, U+C5CE, U+C5D0-C5D1, U+C5D4, U+C5D8, + U+C5E0-C5E1, U+C5E3, U+C5E5, U+C5EC-C5EE, U+C5F0, U+C5F4, U+C5F6-C5F7, U+C5FC-C601, U+C605-C608, U+C60C, + U+C610, U+C618-C619, U+C61B-C61C, U+C624-C625, U+C628, U+C62C-C62E, U+C630, U+C633-C635, U+C637, U+C639, + U+C63B, U+C640-C641, U+C644, U+C648, U+C650-C651, U+C653-C655, U+C65C-C65D, U+C660, U+C664, U+C66C, + U+C66F, U+C671, U+C674, U+C678-C679, U+C67C, U+C680, U+C688-C689, U+C68B, U+C68D, U+C694-C695, + U+C698, U+C69C, U+C6A4-C6A5, U+C6A7, U+C6A9, U+C6B0-C6B1, U+C6B4, U+C6B8-C6BA, U+C6C0-C6C1, U+C6C3, + U+C6C5, U+C6CC-C6CD, U+C6D0, U+C6D4, U+C6DC-C6DD, U+C6DF-C6E1, U+C6E8-C6E9, U+C6EC, U+C6F0-C6F1, U+C6F8-C6F9, + U+C6FD, U+C704-C705, U+C708, U+C70C, U+C714-C715, U+C717, U+C719, U+C720-C721, U+C724, U+C728, + U+C730-C731, U+C733, U+C735, U+C737, U+C73C-C73E, U+C740, U+C743-C744, U+C74A, U+C74C-C74F, U+C751-C758, + U+C75C, U+C760, U+C768, U+C76B, U+C774-C776, U+C778, U+C77C-C77E, U+C783-C785, U+C787-C78A, U+C78C, + U+C78E, U+C790-C791, U+C794, U+C796-C798, U+C79A, U+C7A0-C7A1, U+C7A3-C7A6, U+C7AC-C7AD, U+C7B0, U+C7B4, + U+C7BC-C7BD, U+C7BF-C7C1, U+C7C8-C7C9, U+C7CC, U+C7CE, U+C7D0, U+C7D8-C7D9, U+C7DD, U+C7E4, U+C7E8, + U+C7EC, U+C800-C801, U+C804, U+C808, U+C80A, U+C810-C811, U+C813, U+C815-C816, U+C81C-C81D, U+C820, + U+C824, U+C82C-C82D, U+C82F, U+C831, U+C838, U+C83C, U+C840, U+C848-C849, U+C84C-C84D, U+C854, + U+C870-C871, U+C874, U+C878, U+C87A, U+C880-C881, U+C883, U+C885-C887, U+C88B-C88D, U+C890, U+C894, + U+C89D, U+C89F, U+C8A1, U+C8A8, U+C8BC-C8BD, U+C8C4, U+C8C8, U+C8CC, U+C8D4-C8D5, U+C8D7, + U+C8D9, U+C8E0-C8E1, U+C8E4, U+C8E8, U+C8F5, U+C8FC-C8FD, U+C900, U+C904-C906, U+C90C-C90D, U+C90F, + U+C911, U+C918, U+C92C, U+C934-C935, U+C938, U+C950-C951, U+C954, U+C958, U+C960-C961, U+C963, + U+C965, U+C96C, U+C970, U+C974, U+C97C, U+C981, U+C988-C989, U+C98C, U+C990, U+C998-C999, + U+C99B, U+C99D, U+C9A4, U+C9A8, U+C9C0-C9C1, U+C9C4, U+C9C7-C9C8, U+C9CA, U+C9D0-C9D1, U+C9D3, + U+C9D5-C9D6, U+C9D9-C9DA, U+C9DC-C9DD, U+C9E0, U+C9E2, U+C9E4, U+C9E7, U+C9EC-C9ED, U+C9EF-C9F1, U+C9F8-C9F9, + U+C9FC, U+CA00, U+CA08-CA09, U+CA0B-CA0D, U+CA14, U+CA18, U+CA1C, U+CA29, U+CA30, U+CA4C-CA4D, + U+CA50, U+CA54, U+CA5C-CA5D, U+CA5F-CA61, U+CA68, U+CA7D, U+CA84, U+CA98, U+CABC-CABD, U+CAC0, + U+CAC4, U+CACC-CACD, U+CACF, U+CAD1, U+CAD3, U+CAD8-CAD9, U+CAE0, U+CAEC, U+CAF4, U+CB08, + U+CB10, U+CB14, U+CB18, U+CB20-CB21, U+CB2C, U+CB41, U+CB48-CB49, U+CB4C, U+CB50, U+CB58-CB59, + U+CB5D, U+CB64, U+CB78-CB79, U+CB9C, U+CBB8, U+CBD4, U+CBE4-CBE5, U+CBE7, U+CBE9, U+CC0C-CC0D, + U+CC10, U+CC14, U+CC1C-CC1D, U+CC21-CC22, U+CC27-CC29, U+CC2C, U+CC2E, U+CC30, U+CC38-CC39, U+CC3B-CC3E, + U+CC44-CC45, U+CC48, U+CC4C, U+CC54-CC55, U+CC57-CC59, U+CC60, U+CC64, U+CC66, U+CC68, U+CC70, + U+CC75, U+CC98-CC99, U+CC9C, U+CCA0, U+CCA8-CCA9, U+CCAB-CCAD, U+CCB4-CCB5, U+CCB8, U+CCBC, U+CCC4-CCC5, + U+CCC7, U+CCC9, U+CCD0, U+CCD4, U+CCE1, U+CCE4, U+CCEC, U+CCF0, U+CD01, U+CD08-CD09, + U+CD0C, U+CD10, U+CD18-CD19, U+CD1B, U+CD1D, U+CD24, U+CD28, U+CD2C, U+CD39, U+CD5C-CD5D, + U+CD60, U+CD64, U+CD6C-CD6D, U+CD6F, U+CD71, U+CD78, U+CD88, U+CD94-CD95, U+CD98, U+CD9C, + U+CDA4-CDA5, U+CDA7, U+CDA9, U+CDB0, U+CDC4, U+CDCC-CDCD, U+CDD0, U+CDDD, U+CDDF, U+CDE8, + U+CDEC, U+CDF0, U+CDF8-CDF9, U+CDFB, U+CDFD, U+CE04, U+CE08, U+CE0C, U+CE14-CE15, U+CE19, + U+CE20-CE21, U+CE24, U+CE28, U+CE30-CE31, U+CE33, U+CE35, U+CE58-CE59, U+CE5C, U+CE5F-CE61, U+CE68-CE69, + U+CE6B, U+CE6D, U+CE74-CE75, U+CE78, U+CE7C, U+CE84-CE85, U+CE87, U+CE89, U+CE8C, U+CE90-CE91, + U+CE94, U+CE98, U+CEA0-CEA1, U+CEA3-CEA5, U+CEAC-CEAE, U+CEB4, U+CEC1, U+CEC4, U+CEE4-CEE5, U+CEE8, + U+CEEB-CEEC, U+CEF4-CEF5, U+CEF7-CEF9, U+CF00-CF01, U+CF04, U+CF08, U+CF10-CF11, U+CF13, U+CF15, U+CF18, + U+CF1C, U+CF20, U+CF24, U+CF2C-CF2D, U+CF2F-CF31, U+CF38, U+CF54-CF55, U+CF58, U+CF5C, U+CF64-CF65, + U+CF67, U+CF69, U+CF70-CF71, U+CF74, U+CF78, U+CF80, U+CF85, U+CF8C, U+CFA1, U+CFA8, + U+CFB0, U+CFC4, U+CFDC, U+CFE0-CFE1, U+CFE4, U+CFE8, U+CFF0-CFF1, U+CFF3, U+CFF5, U+CFFC-CFFD, + U+D000, U+D004, U+D011, U+D018, U+D02D, U+D034-D035, U+D038, U+D03C, U+D044-D045, U+D047, + U+D049, U+D050, U+D054, U+D058, U+D060, U+D06C-D06E, U+D070, U+D074, U+D07C-D07D, U+D07F, + U+D081, U+D084, U+D0A4-D0A5, U+D0A8, U+D0AC, U+D0B4-D0B5, U+D0B7, U+D0B9, U+D0C0-D0C1, U+D0C4, + U+D0C8-D0C9, U+D0D0-D0D1, U+D0D3-D0D5, U+D0DC-D0DD, U+D0E0, U+D0E4, U+D0EC-D0ED, U+D0EF-D0F1, U+D0F8, U+D10D, + U+D130-D131, U+D134, U+D138, U+D13A, U+D140-D141, U+D143-D145, U+D14C-D14D, U+D150, U+D154, U+D15C-D15D, + U+D15F, U+D161, U+D168, U+D16C, U+D17C, U+D184, U+D188, U+D1A0-D1A1, U+D1A4, U+D1A8, + U+D1B0-D1B1, U+D1B3, U+D1B5, U+D1BA, U+D1BC, U+D1C0, U+D1D8, U+D1F4, U+D1F8, U+D207, + U+D209, U+D210, U+D22C-D22D, U+D230, U+D234, U+D23C-D23D, U+D23F, U+D241, U+D248, U+D25C, + U+D264, U+D277, U+D280-D281, U+D284, U+D288, U+D290-D291, U+D295, U+D29C, U+D2A0, U+D2A4, + U+D2AC, U+D2B1, U+D2B8-D2B9, U+D2BC, U+D2BF-D2C0, U+D2C2, U+D2C8-D2C9, U+D2CB, U+D2D4, U+D2D8, + U+D2DC, U+D2E4-D2E5, U+D2F0-D2F1, U+D2F4, U+D2F8, U+D300-D301, U+D303, U+D305, U+D30C-D30E, U+D310, + U+D314, U+D316, U+D31C-D31D, U+D31F-D321, U+D325, U+D328-D329, U+D32C, U+D330, U+D338-D339, U+D33B-D33D, + U+D344-D345, U+D37C-D37D, U+D380, U+D384, U+D38C-D38D, U+D38F-D391, U+D398-D399, U+D39C, U+D3A0, U+D3A8-D3A9, + U+D3AB, U+D3AD, U+D3B4, U+D3B8, U+D3BC, U+D3C4-D3C5, U+D3C8-D3C9, U+D3D0, U+D3D8, U+D3E1, + U+D3E3, U+D3EC-D3ED, U+D3F0, U+D3F4, U+D3FC-D3FD, U+D3FF, U+D401, U+D408, U+D41D, U+D440, + U+D444, U+D45C, U+D460, U+D464, U+D46D, U+D46F, U+D478-D479, U+D47C, U+D47F-D480, U+D482, + U+D488-D489, U+D48B, U+D48D, U+D494, U+D4A9, U+D4B0, U+D4CC, U+D4D0, U+D4D4, U+D4DC, + U+D4DF, U+D4E8, U+D4EC, U+D4F0, U+D4F8, U+D4FB, U+D4FD, U+D504, U+D508, U+D50C, + U+D514-D515, U+D517, U+D53C-D53D, U+D540, U+D544, U+D54C-D54D, U+D54F, U+D551, U+D558-D559, U+D55C, + U+D560, U+D565, U+D568-D569, U+D56B, U+D56D, U+D571, U+D573-D575, U+D578, U+D57C, U+D584-D585, + U+D587-D589, U+D58F-D590, U+D59D, U+D5A5, U+D5C8-D5C9, U+D5CC, U+D5D0, U+D5D2, U+D5D8-D5D9, U+D5DB, + U+D5DD, U+D5E4-D5E5, U+D5E8, U+D5EC, U+D5F3-D5F5, U+D5F7, U+D5F9, U+D5FF-D601, U+D604, U+D608, + U+D610-D611, U+D613-D615, U+D61C, U+D620, U+D624, U+D62D, U+D638-D639, U+D63C, U+D640, U+D645, + U+D648-D649, U+D64B, U+D64D, U+D651, U+D654-D655, U+D658, U+D65C, U+D664, U+D667, U+D669, + U+D670-D671, U+D674, U+D683, U+D685, U+D68C-D68D, U+D690, U+D694, U+D69D, U+D69F, U+D6A1, + U+D6A8, U+D6AC, U+D6B0, U+D6B9, U+D6BB, U+D6BD, U+D6C3-D6C5, U+D6C8, U+D6CC, U+D6D1, + U+D6D4-D6D5, U+D6D7, U+D6D9, U+D6E0, U+D6E4, U+D6E8, U+D6F0, U+D6F5, U+D6FC-D6FD, U+D700, + U+D704, U+D711, U+D718-D719, U+D71C, U+D720, U+D728-D729, U+D72B, U+D72D, U+D734-D735, U+D738, + U+D73C, U+D744, U+D747, U+D749, U+D750-D751, U+D754, U+D756-D759, U+D760-D761, U+D763, U+D765, + U+D769, U+D76C, U+D770, U+D774, U+D77C-D77D, U+D781, U+D788-D789, U+D78C, U+D790, U+D798-D799, + U+D79B, U+D79D, U+FF01-FF5E, U+FFE0-FFE3, U+FFE5-FFE6; +} + +/* NanumSquare.woff2 sha256: 2e9ab2497693544b0dfeebbc409ac64a1da2f946748f360f34fc71cd2475185e */ +@font-face { + font-family: 'NanumSquare'; + src: url('/assets/fonts/NanumSquare.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; + unicode-range: + U+20-7E, U+A1, U+A4, U+A7-AB, U+B0-B4, U+B6, U+B8-BF, U+C6, U+D0, U+D7-D8, + U+DE-DF, U+E6, U+F0, U+F7-F8, U+FE, U+111, U+126-127, U+131-133, U+138, U+13F-142, + U+149-14B, U+152-153, U+166-167, U+2C7, U+2D8-2DD, U+391-3A1, U+3A3-3A9, U+3B1-3C1, U+3C3-3C9, U+401, + U+410-44F, U+451, U+2010, U+2013-2014, U+2016, U+2018-2019, U+201C-201D, U+2020-2021, U+2025-2026, U+2030, + U+2032-2033, U+203B, U+2074, U+207F, U+2081-2084, U+20A9, U+2103, U+2109, U+2113, U+2116, + U+2121-2122, U+2126, U+212B, U+2153-2154, U+215B-215E, U+2160-2169, U+2170-2179, U+2190-2199, U+21D2, U+21D4, + U+2200, U+2202-2203, U+2207-2208, U+220B, U+220F, U+2211, U+221A, U+221D-221E, U+2220, U+2227-222C, + U+222E, U+2234-2236, U+223D, U+2252, U+2260-2261, U+2264-2265, U+2282-2283, U+2286-2287, U+22A5, U+22EF, + U+2312, U+2460-246E, U+2474-2482, U+249C-24B5, U+24D0-24E9, U+2500-2503, U+250C-254B, U+2592, U+25A0-25A1, U+25A3-25A9, + U+25B2-25B3, U+25B6-25B7, U+25BC-25BD, U+25C0-25C1, U+25C6-25C9, U+25CB, U+25CE-25D1, U+2605-2606, U+260E-260F, U+261C, + U+261E, U+2640, U+2642, U+2660-2661, U+2663-2665, U+2667-266A, U+266C-266D, U+3000-3003, U+3008-3011, U+3013-3015, + U+3041-3093, U+30A1-30F6, U+30FB, U+3131-318E, U+3200-321C, U+3260-327B, U+327F, U+3380-3384, U+3388-33CA, U+33CF-33D0, + U+33D3, U+33D6, U+33D8, U+33DB-33DD, U+AC00-AC01, U+AC04, U+AC07-AC0A, U+AC10-AC17, U+AC19-AC1D, U+AC20, + U+AC24, U+AC2C-AC2D, U+AC2F-AC31, U+AC38-AC39, U+AC3C, U+AC40, U+AC4B, U+AC4D, U+AC54, U+AC58, + U+AC5C, U+AC70-AC71, U+AC74, U+AC77-AC78, U+AC7A, U+AC80-AC81, U+AC83-AC86, U+AC89-AC8C, U+AC90, U+AC94, + U+AC9C-AC9D, U+AC9F-ACA1, U+ACA8-ACAA, U+ACAC, U+ACAF-ACB0, U+ACB8-ACB9, U+ACBB-ACBD, U+ACC1, U+ACC4, U+ACC8, + U+ACCC, U+ACD5, U+ACD7, U+ACE0-ACE1, U+ACE4, U+ACE7-ACE8, U+ACEA, U+ACEC, U+ACEF-ACF1, U+ACF3, + U+ACF5-ACF6, U+ACFC-ACFD, U+AD00, U+AD04, U+AD06, U+AD0C-AD0D, U+AD0F, U+AD11, U+AD18, U+AD1C, + U+AD1E, U+AD20, U+AD29, U+AD2C-AD2D, U+AD34-AD35, U+AD38, U+AD3C, U+AD44-AD45, U+AD47, U+AD49, + U+AD50, U+AD54, U+AD58, U+AD61, U+AD63, U+AD65, U+AD6C-AD6D, U+AD70, U+AD73-AD76, U+AD7B-AD7D, + U+AD7F, U+AD81-AD82, U+AD88-AD89, U+AD8C, U+AD90, U+AD9C-AD9D, U+ADA4, U+ADB4, U+ADB7, U+ADC0-ADC1, + U+ADC4, U+ADC8, U+ADD0-ADD1, U+ADD3, U+ADDC, U+ADE0, U+ADE4, U+ADF8-ADF9, U+ADFC, U+ADFF-AE01, + U+AE08-AE09, U+AE0B, U+AE0D, U+AE14, U+AE1C, U+AE30-AE31, U+AE34, U+AE37-AE38, U+AE3A, U+AE40-AE41, + U+AE43, U+AE45-AE46, U+AE4A, U+AE4C-AE4E, U+AE50, U+AE54, U+AE56, U+AE5C-AE5D, U+AE5F-AE61, U+AE65, + U+AE68-AE69, U+AE6C, U+AE70, U+AE78-AE79, U+AE7B-AE7D, U+AE84-AE86, U+AE8C-AE8D, U+AEA0, U+AEBC-AEBE, U+AEC0, + U+AEC4, U+AECC-AECD, U+AECF-AED1, U+AED8-AED9, U+AEDC, U+AEE8, U+AEEB, U+AEED, U+AEF4, U+AEF8, + U+AEFC, U+AF07-AF08, U+AF0D, U+AF10, U+AF2C-AF2D, U+AF30, U+AF32, U+AF34, U+AF3C-AF3D, U+AF3F, + U+AF41-AF43, U+AF48-AF49, U+AF50, U+AF5C-AF5D, U+AF64-AF65, U+AF79, U+AF80, U+AF84, U+AF88, U+AF90-AF91, + U+AF95, U+AF9C, U+AFA4, U+AFB8-AFBA, U+AFBC, U+AFC0, U+AFC7-AFC9, U+AFCB, U+AFCD-AFCE, U+AFD4, + U+AFDC, U+AFE8-AFE9, U+AFF0-AFF1, U+AFF4, U+AFF8-AFF9, U+AFFF-B001, U+B004, U+B00C, U+B010, U+B014, + U+B01C-B01D, U+B028, U+B044-B045, U+B048, U+B04A, U+B04C, U+B04E, U+B053-B055, U+B057, U+B059, + U+B05D, U+B068, U+B07C-B07D, U+B080, U+B084, U+B08C-B08D, U+B08F, U+B091, U+B098-B09A, U+B09C, + U+B09F-B0A2, U+B0A8-B0A9, U+B0AB-B0AF, U+B0B1, U+B0B3-B0B5, U+B0B8, U+B0BC, U+B0C4-B0C5, U+B0C7-B0C9, U+B0D0-B0D1, + U+B0D4, U+B0D8, U+B0E0, U+B0E5, U+B108-B109, U+B10B-B10C, U+B110, U+B112-B113, U+B118-B119, U+B11B-B11D, + U+B123-B125, U+B128, U+B12C, U+B134-B135, U+B137-B139, U+B140-B141, U+B144, U+B148, U+B150-B151, U+B154-B155, + U+B158, U+B15C, U+B160, U+B178-B179, U+B17C, U+B180, U+B182, U+B188-B189, U+B18B, U+B18D, + U+B192-B194, U+B198, U+B19C, U+B1A8, U+B1CC, U+B1D0, U+B1D4, U+B1DC-B1DD, U+B1DF, U+B1E8-B1E9, + U+B1EC, U+B1F0, U+B1F9, U+B1FB, U+B1FD, U+B204-B205, U+B208, U+B20B-B20C, U+B214-B215, U+B217, + U+B219, U+B220, U+B234, U+B23C, U+B24C-B24D, U+B251, U+B258, U+B25C, U+B260, U+B268-B269, + U+B274-B275, U+B27C, U+B284-B285, U+B289, U+B290-B291, U+B294, U+B298-B29A, U+B2A0-B2A1, U+B2A3, U+B2A5-B2A6, + U+B2AA, U+B2AC, U+B2B0, U+B2B4, U+B2C8-B2C9, U+B2CC, U+B2D0, U+B2D2, U+B2D8-B2D9, U+B2DB, + U+B2DD, U+B2E2, U+B2E4-B2E6, U+B2E8, U+B2EB-B2EF, U+B2F3-B2F5, U+B2F7-B2FB, U+B2FF-B301, U+B304, U+B308, + U+B310-B311, U+B313-B315, U+B31C, U+B324, U+B32C, U+B331, U+B354-B356, U+B358, U+B35B-B35C, U+B35E-B35F, + U+B364-B365, U+B367, U+B369, U+B36B, U+B36E, U+B370-B371, U+B374, U+B378, U+B380-B381, U+B383-B385, + U+B38C, U+B390, U+B394, U+B3A0-B3A1, U+B3A8, U+B3AC, U+B3C4-B3C5, U+B3C8, U+B3CB-B3CC, U+B3CE, + U+B3D0, U+B3D4-B3D5, U+B3D7, U+B3D9, U+B3DB, U+B3DD, U+B3E0, U+B3E4, U+B3E8, U+B3FC, + U+B400, U+B40F-B411, U+B418, U+B41C, U+B420, U+B428-B429, U+B42B-B42D, U+B434, U+B450-B451, U+B454, + U+B458, U+B460-B461, U+B463, U+B465, U+B46C, U+B480, U+B488, U+B49D, U+B4A4, U+B4A8, + U+B4AC, U+B4B5, U+B4B7, U+B4B9, U+B4C0, U+B4C4, U+B4C8, U+B4D0, U+B4D5, U+B4DC-B4DD, + U+B4E0, U+B4E3-B4E4, U+B4E6, U+B4EC-B4ED, U+B4EF, U+B4F1, U+B4F8, U+B514-B515, U+B518, U+B51B-B51C, + U+B524-B525, U+B527-B52A, U+B530-B531, U+B534, U+B538, U+B540-B541, U+B543-B545, U+B54B-B54D, U+B550, U+B554, + U+B55C-B55D, U+B55F-B561, U+B568, U+B584, U+B599, U+B5A0-B5A1, U+B5A4, U+B5A8, U+B5AA-B5AB, U+B5B0-B5B1, + U+B5B3-B5B5, U+B5BB-B5BD, U+B5C0, U+B5C4, U+B5CC-B5CD, U+B5CF-B5D1, U+B5D8, U+B5EC, U+B610-B611, U+B614, + U+B618, U+B620, U+B625, U+B62C, U+B634, U+B648, U+B65D, U+B664, U+B668, U+B69C-B69D, + U+B6A0, U+B6A4, U+B6AB-B6AC, U+B6B1, U+B6D4, U+B6F0, U+B6F4, U+B6F8, U+B700-B701, U+B705, + U+B71B, U+B728-B729, U+B72C, U+B72F-B730, U+B738-B739, U+B73B, U+B73D, U+B744, U+B748, U+B74C, + U+B754-B755, U+B760-B761, U+B764, U+B768, U+B770-B771, U+B773, U+B775, U+B77C-B77D, U+B780, U+B784, + U+B78C-B78D, U+B78F-B792, U+B796-B799, U+B79C, U+B7A0, U+B7A8-B7A9, U+B7AB-B7AD, U+B7B4-B7B5, U+B7B8, U+B7BC, + U+B7C7, U+B7C9, U+B7EC-B7ED, U+B7F0, U+B7F4, U+B7FC-B7FD, U+B7FF-B801, U+B807-B809, U+B80C, U+B810, + U+B818-B819, U+B81B, U+B81D, U+B824-B825, U+B828, U+B82C, U+B834-B835, U+B837-B839, U+B840, U+B844, + U+B851, U+B853, U+B85C-B85D, U+B860, U+B864, U+B86C-B86D, U+B86F, U+B871, U+B878-B879, U+B87C, + U+B880, U+B88D, U+B894, U+B8A8, U+B8B0, U+B8B4, U+B8B8, U+B8C0-B8C1, U+B8C3, U+B8C5, + U+B8CC, U+B8D0, U+B8D4, U+B8DD, U+B8DF, U+B8E1, U+B8E8-B8E9, U+B8EC, U+B8F0, U+B8F8-B8F9, + U+B8FB, U+B8FD, U+B904, U+B918, U+B920, U+B93C-B93D, U+B940, U+B944, U+B94C, U+B94F, + U+B951, U+B958-B959, U+B95C, U+B960, U+B968-B969, U+B96B, U+B96D, U+B974-B975, U+B978, U+B97C, + U+B984-B985, U+B987, U+B989-B98A, U+B98D-B98E, U+B9AC-B9AD, U+B9B0, U+B9B4, U+B9BC-B9BD, U+B9BF, U+B9C1, + U+B9C8-B9C9, U+B9CC, U+B9CE-B9D2, U+B9D8-B9D9, U+B9DB, U+B9DD-B9DE, U+B9E1, U+B9E3-B9E5, U+B9E8, U+B9EC, + U+B9F4-B9F5, U+B9F7-B9FA, U+BA00-BA01, U+BA04, U+BA08, U+BA15, U+BA38-BA39, U+BA3C, U+BA40, U+BA42, + U+BA48-BA49, U+BA4B, U+BA4D-BA4E, U+BA53-BA55, U+BA58, U+BA5C, U+BA64-BA65, U+BA67-BA69, U+BA70-BA71, U+BA74, + U+BA78, U+BA83-BA85, U+BA87, U+BA8C, U+BAA8-BAA9, U+BAAB-BAAC, U+BAB0, U+BAB2, U+BAB8-BAB9, U+BABB, + U+BABD, U+BAC4, U+BAC8, U+BAD8-BAD9, U+BAFC, U+BB00, U+BB04, U+BB0D, U+BB0F, U+BB11, + U+BB18, U+BB1C, U+BB20, U+BB29, U+BB2B, U+BB34-BB36, U+BB38, U+BB3B-BB3E, U+BB44-BB45, U+BB47, + U+BB49, U+BB4D, U+BB4F-BB50, U+BB54, U+BB58, U+BB61, U+BB63, U+BB65, U+BB6C, U+BB88, + U+BB8C, U+BB90, U+BBA4, U+BBA8, U+BBAC, U+BBB4, U+BBB7, U+BBC0, U+BBC4, U+BBC8, + U+BBD0, U+BBD3, U+BBDC, U+BBF8-BBF9, U+BBFC, U+BBFF-BC00, U+BC02, U+BC08-BC09, U+BC0B-BC0D, U+BC0F, + U+BC11, U+BC14-BC18, U+BC1B-BC1F, U+BC24-BC25, U+BC27, U+BC29, U+BC2D, U+BC30-BC31, U+BC34, U+BC38, + U+BC40-BC41, U+BC43-BC45, U+BC49, U+BC4C-BC4D, U+BC50, U+BC5D, U+BC84-BC85, U+BC88, U+BC8B-BC8C, U+BC8E, + U+BC94-BC95, U+BC97, U+BC99-BC9A, U+BCA0-BCA1, U+BCA4, U+BCA7-BCA9, U+BCB0-BCB1, U+BCB3-BCB5, U+BCBC-BCBD, U+BCC0, + U+BCC4, U+BCCD, U+BCCF-BCD1, U+BCD5, U+BCD8, U+BCDC, U+BCF4-BCF6, U+BCF8, U+BCFC, U+BD04-BD05, + U+BD07, U+BD09, U+BD10, U+BD14, U+BD23-BD25, U+BD2C, U+BD35, U+BD40, U+BD48-BD49, U+BD4C, + U+BD50, U+BD58-BD59, U+BD64, U+BD68, U+BD79, U+BD80-BD81, U+BD84, U+BD87-BD8A, U+BD90-BD91, U+BD93, + U+BD95, U+BD99-BD9A, U+BD9C, U+BDA4, U+BDB0, U+BDB8-BDB9, U+BDC1, U+BDD4-BDD5, U+BDD8, U+BDDC, + U+BDE9, U+BDF0, U+BDF4, U+BDF8, U+BE00, U+BE03, U+BE05, U+BE0C-BE0D, U+BE10, U+BE14, + U+BE1C-BE1D, U+BE1F, U+BE44-BE45, U+BE48, U+BE4C, U+BE4E, U+BE54-BE55, U+BE57, U+BE59-BE5B, U+BE60-BE61, + U+BE64, U+BE68, U+BE6A, U+BE70-BE71, U+BE73-BE75, U+BE7B-BE7D, U+BE80, U+BE84, U+BE8C-BE8D, U+BE8F-BE91, + U+BE98-BE99, U+BEA8, U+BEAD, U+BEB4, U+BED0-BED1, U+BED4, U+BED7-BED8, U+BEE0, U+BEE3-BEE5, U+BEEC, + U+BEF5, U+BF01, U+BF08-BF09, U+BF18-BF19, U+BF1B-BF1D, U+BF40-BF41, U+BF44, U+BF48, U+BF50-BF51, U+BF55, + U+BF5C, U+BF71, U+BF94, U+BFB0, U+BFB8, U+BFC5, U+BFCC-BFCD, U+BFD0, U+BFD4, U+BFDC, + U+BFDF, U+BFE1, U+C00D, U+C03C, U+C04C, U+C051, U+C058, U+C05C, U+C060, U+C068-C069, + U+C090-C091, U+C094, U+C098, U+C0A0-C0A1, U+C0A3, U+C0A5, U+C0AC-C0AD, U+C0AF-C0B0, U+C0B3-C0B6, U+C0BC-C0BD, + U+C0BF-C0C1, U+C0C5, U+C0C8-C0C9, U+C0CC, U+C0D0, U+C0D8-C0D9, U+C0DB-C0DD, U+C0E4-C0E5, U+C0E8, U+C0EC, + U+C0F4-C0F5, U+C0F7, U+C0F9, U+C0FE, U+C100, U+C104, U+C108, U+C110, U+C115, U+C11C-C120, + U+C123-C124, U+C126-C127, U+C12C-C12D, U+C12F-C131, U+C136, U+C138-C139, U+C13C, U+C140, U+C142, U+C148-C149, + U+C14B-C14D, U+C154-C155, U+C158, U+C15C, U+C164-C165, U+C167-C169, U+C170, U+C174, U+C178, U+C185, + U+C18C-C18E, U+C190, U+C194, U+C196, U+C19C-C19D, U+C19F, U+C1A1, U+C1A5, U+C1A8-C1A9, U+C1AC, + U+C1B0, U+C1BD, U+C1C4, U+C1C8, U+C1CC, U+C1D4, U+C1D7-C1D8, U+C1E0, U+C1E4, U+C1E8, + U+C1F0-C1F1, U+C1F3, U+C1FC-C1FD, U+C200, U+C204, U+C20C-C20D, U+C20F, U+C211, U+C216, U+C218-C219, + U+C21C, U+C21F-C220, U+C228-C229, U+C22B, U+C22D, U+C22F, U+C231-C232, U+C234, U+C245, U+C248, + U+C250-C251, U+C254, U+C258, U+C25F-C260, U+C265, U+C26C-C26D, U+C270, U+C274, U+C27C-C27D, U+C27F, + U+C281, U+C288-C289, U+C28C, U+C290, U+C298-C299, U+C29B, U+C29D, U+C2A0, U+C2A4-C2A5, U+C2A8, + U+C2AC-C2AD, U+C2B4-C2B5, U+C2B7, U+C2B9, U+C2DC-C2DD, U+C2E0, U+C2E3-C2E4, U+C2EB-C2ED, U+C2EF, U+C2F1, + U+C2F6, U+C2F8-C2F9, U+C2FB-C2FC, U+C300, U+C308-C309, U+C30C-C30D, U+C313-C315, U+C318, U+C31C, U+C324-C325, + U+C328-C329, U+C330, U+C338, U+C345, U+C34C, U+C368-C369, U+C36C, U+C370, U+C372, U+C378-C379, + U+C37B-C37D, U+C384, U+C388, U+C38C, U+C395, U+C399, U+C3B5, U+C3BC, U+C3C0, U+C3D8-C3D9, + U+C3DC, U+C3DF-C3E0, U+C3E2, U+C3E8-C3E9, U+C3ED, U+C3F4-C3F5, U+C3F8, U+C3FC, U+C408, U+C410, + U+C424, U+C42C, U+C430, U+C434, U+C43C-C43D, U+C448, U+C450, U+C45D, U+C464-C465, U+C468, + U+C46C, U+C474-C475, U+C479, U+C480, U+C494, U+C49C-C49D, U+C4B8, U+C4BC, U+C4CD, U+C4D4, + U+C4D8, U+C4E9, U+C4F0-C4F1, U+C4F4, U+C4F8, U+C4FA, U+C4FF-C501, U+C50C, U+C510, U+C514, + U+C51C, U+C521, U+C528-C529, U+C52C, U+C530, U+C538-C539, U+C53B, U+C53D, U+C544-C545, U+C548-C54A, + U+C54C-C54E, U+C553-C555, U+C557-C559, U+C55C-C55E, U+C560-C561, U+C564, U+C568, U+C570-C571, U+C573-C575, U+C57B-C57D, + U+C580, U+C584, U+C587, U+C58C-C58D, U+C58F, U+C591, U+C595, U+C597-C598, U+C59C, U+C5A0, + U+C5A9, U+C5B4-C5B5, U+C5B8-C5B9, U+C5BB-C5BE, U+C5C4-C5CA, U+C5CC, U+C5CE, U+C5D0-C5D1, U+C5D4, U+C5D8, + U+C5E0-C5E1, U+C5E3, U+C5E5, U+C5EC-C5EE, U+C5F0, U+C5F4, U+C5F6-C5F7, U+C5FC-C601, U+C605-C608, U+C60C, + U+C610, U+C618-C619, U+C61B-C61C, U+C624-C625, U+C628, U+C62C-C62E, U+C630, U+C633-C635, U+C637, U+C639, + U+C63B, U+C640-C641, U+C644, U+C648, U+C650-C651, U+C653-C655, U+C65C-C65D, U+C660, U+C664, U+C66C, + U+C66F, U+C671, U+C674, U+C678-C679, U+C67C, U+C680, U+C688-C689, U+C68B, U+C68D, U+C694-C695, + U+C698, U+C69C, U+C6A4-C6A5, U+C6A7, U+C6A9, U+C6B0-C6B1, U+C6B4, U+C6B8-C6BA, U+C6C0-C6C1, U+C6C3, + U+C6C5, U+C6CC-C6CD, U+C6D0, U+C6D4, U+C6DC-C6DD, U+C6DF-C6E1, U+C6E8-C6E9, U+C6EC, U+C6F0-C6F1, U+C6F8-C6F9, + U+C6FD, U+C704-C705, U+C708, U+C70C, U+C714-C715, U+C717, U+C719, U+C720-C721, U+C724, U+C728, + U+C730-C731, U+C733, U+C735, U+C737, U+C73C-C73E, U+C740, U+C743-C744, U+C74A, U+C74C-C74F, U+C751-C758, + U+C75C, U+C760, U+C768, U+C76B, U+C774-C776, U+C778, U+C77C-C77E, U+C783-C785, U+C787-C78A, U+C78C, + U+C78E, U+C790-C791, U+C794, U+C796-C798, U+C79A, U+C7A0-C7A1, U+C7A3-C7A6, U+C7AC-C7AD, U+C7B0, U+C7B4, + U+C7BC-C7BD, U+C7BF-C7C1, U+C7C8-C7C9, U+C7CC, U+C7CE, U+C7D0, U+C7D8-C7D9, U+C7DD, U+C7E4, U+C7E8, + U+C7EC, U+C800-C801, U+C804, U+C808, U+C80A, U+C810-C811, U+C813, U+C815-C816, U+C81C-C81D, U+C820, + U+C824, U+C82C-C82D, U+C82F, U+C831, U+C838, U+C83C, U+C840, U+C848-C849, U+C84C-C84D, U+C854, + U+C870-C871, U+C874, U+C878, U+C87A, U+C880-C881, U+C883, U+C885-C887, U+C88B-C88D, U+C890, U+C894, + U+C89D, U+C89F, U+C8A1, U+C8A8, U+C8BC-C8BD, U+C8C4, U+C8C8, U+C8CC, U+C8D4-C8D5, U+C8D7, + U+C8D9, U+C8E0-C8E1, U+C8E4, U+C8E8, U+C8F5, U+C8FC-C8FD, U+C900, U+C904-C906, U+C90C-C90D, U+C90F, + U+C911, U+C918, U+C92C, U+C934-C935, U+C938, U+C950-C951, U+C954, U+C958, U+C960-C961, U+C963, + U+C965, U+C96C, U+C970, U+C974, U+C97C, U+C981, U+C988-C989, U+C98C, U+C990, U+C998-C999, + U+C99B, U+C99D, U+C9A4, U+C9A8, U+C9C0-C9C1, U+C9C4, U+C9C7-C9C8, U+C9CA, U+C9D0-C9D1, U+C9D3, + U+C9D5-C9D6, U+C9D9-C9DA, U+C9DC-C9DD, U+C9E0, U+C9E2, U+C9E4, U+C9E7, U+C9EC-C9ED, U+C9EF-C9F1, U+C9F8-C9F9, + U+C9FC, U+CA00, U+CA08-CA09, U+CA0B-CA0D, U+CA14, U+CA18, U+CA1C, U+CA29, U+CA30, U+CA4C-CA4D, + U+CA50, U+CA54, U+CA5C-CA5D, U+CA5F-CA61, U+CA68, U+CA7D, U+CA84, U+CA98, U+CABC-CABD, U+CAC0, + U+CAC4, U+CACC-CACD, U+CACF, U+CAD1, U+CAD3, U+CAD8-CAD9, U+CAE0, U+CAEC, U+CAF4, U+CB08, + U+CB10, U+CB14, U+CB18, U+CB20-CB21, U+CB2C, U+CB41, U+CB48-CB49, U+CB4C, U+CB50, U+CB58-CB59, + U+CB5D, U+CB64, U+CB78-CB79, U+CB9C, U+CBB8, U+CBD4, U+CBE4-CBE5, U+CBE7, U+CBE9, U+CC0C-CC0D, + U+CC10, U+CC14, U+CC1C-CC1D, U+CC21-CC22, U+CC27-CC29, U+CC2C, U+CC2E, U+CC30, U+CC38-CC39, U+CC3B-CC3E, + U+CC44-CC45, U+CC48, U+CC4C, U+CC54-CC55, U+CC57-CC59, U+CC60, U+CC64, U+CC66, U+CC68, U+CC70, + U+CC75, U+CC98-CC99, U+CC9C, U+CCA0, U+CCA8-CCA9, U+CCAB-CCAD, U+CCB4-CCB5, U+CCB8, U+CCBC, U+CCC4-CCC5, + U+CCC7, U+CCC9, U+CCD0, U+CCD4, U+CCE1, U+CCE4, U+CCEC, U+CCF0, U+CD01, U+CD08-CD09, + U+CD0C, U+CD10, U+CD18-CD19, U+CD1B, U+CD1D, U+CD24, U+CD28, U+CD2C, U+CD39, U+CD5C-CD5D, + U+CD60, U+CD64, U+CD6C-CD6D, U+CD6F, U+CD71, U+CD78, U+CD88, U+CD94-CD95, U+CD98, U+CD9C, + U+CDA4-CDA5, U+CDA7, U+CDA9, U+CDB0, U+CDC4, U+CDCC-CDCD, U+CDD0, U+CDDD, U+CDDF, U+CDE8, + U+CDEC, U+CDF0, U+CDF8-CDF9, U+CDFB, U+CDFD, U+CE04, U+CE08, U+CE0C, U+CE14-CE15, U+CE19, + U+CE20-CE21, U+CE24, U+CE28, U+CE30-CE31, U+CE33, U+CE35, U+CE58-CE59, U+CE5C, U+CE5F-CE61, U+CE68-CE69, + U+CE6B, U+CE6D, U+CE74-CE75, U+CE78, U+CE7C, U+CE84-CE85, U+CE87, U+CE89, U+CE8C, U+CE90-CE91, + U+CE94, U+CE98, U+CEA0-CEA1, U+CEA3-CEA5, U+CEAC-CEAE, U+CEB4, U+CEC1, U+CEC4, U+CEE4-CEE5, U+CEE8, + U+CEEB-CEEC, U+CEF4-CEF5, U+CEF7-CEF9, U+CF00-CF01, U+CF04, U+CF08, U+CF10-CF11, U+CF13, U+CF15, U+CF18, + U+CF1C, U+CF20, U+CF24, U+CF2C-CF2D, U+CF2F-CF31, U+CF38, U+CF54-CF55, U+CF58, U+CF5C, U+CF64-CF65, + U+CF67, U+CF69, U+CF70-CF71, U+CF74, U+CF78, U+CF80, U+CF85, U+CF8C, U+CFA1, U+CFA8, + U+CFB0, U+CFC4, U+CFDC, U+CFE0-CFE1, U+CFE4, U+CFE8, U+CFF0-CFF1, U+CFF3, U+CFF5, U+CFFC-CFFD, + U+D000, U+D004, U+D011, U+D018, U+D02D, U+D034-D035, U+D038, U+D03C, U+D044-D045, U+D047, + U+D049, U+D050, U+D054, U+D058, U+D060, U+D06C-D06E, U+D070, U+D074, U+D07C-D07D, U+D07F, + U+D081, U+D084, U+D0A4-D0A5, U+D0A8, U+D0AC, U+D0B4-D0B5, U+D0B7, U+D0B9, U+D0C0-D0C1, U+D0C4, + U+D0C8-D0C9, U+D0D0-D0D1, U+D0D3-D0D5, U+D0DC-D0DD, U+D0E0, U+D0E4, U+D0EC-D0ED, U+D0EF-D0F1, U+D0F8, U+D10D, + U+D130-D131, U+D134, U+D138, U+D13A, U+D140-D141, U+D143-D145, U+D14C-D14D, U+D150, U+D154, U+D15C-D15D, + U+D15F, U+D161, U+D168, U+D16C, U+D17C, U+D184, U+D188, U+D1A0-D1A1, U+D1A4, U+D1A8, + U+D1B0-D1B1, U+D1B3, U+D1B5, U+D1BA, U+D1BC, U+D1C0, U+D1D8, U+D1F4, U+D1F8, U+D207, + U+D209, U+D210, U+D22C-D22D, U+D230, U+D234, U+D23C-D23D, U+D23F, U+D241, U+D248, U+D25C, + U+D264, U+D277, U+D280-D281, U+D284, U+D288, U+D290-D291, U+D295, U+D29C, U+D2A0, U+D2A4, + U+D2AC, U+D2B1, U+D2B8-D2B9, U+D2BC, U+D2BF-D2C0, U+D2C2, U+D2C8-D2C9, U+D2CB, U+D2D4, U+D2D8, + U+D2DC, U+D2E4-D2E5, U+D2F0-D2F1, U+D2F4, U+D2F8, U+D300-D301, U+D303, U+D305, U+D30C-D30E, U+D310, + U+D314, U+D316, U+D31C-D31D, U+D31F-D321, U+D325, U+D328-D329, U+D32C, U+D330, U+D338-D339, U+D33B-D33D, + U+D344-D345, U+D37C-D37D, U+D380, U+D384, U+D38C-D38D, U+D38F-D391, U+D398-D399, U+D39C, U+D3A0, U+D3A8-D3A9, + U+D3AB, U+D3AD, U+D3B4, U+D3B8, U+D3BC, U+D3C4-D3C5, U+D3C8-D3C9, U+D3D0, U+D3D8, U+D3E1, + U+D3E3, U+D3EC-D3ED, U+D3F0, U+D3F4, U+D3FC-D3FD, U+D3FF, U+D401, U+D408, U+D41D, U+D440, + U+D444, U+D45C, U+D460, U+D464, U+D46D, U+D46F, U+D478-D479, U+D47C, U+D47F-D480, U+D482, + U+D488-D489, U+D48B, U+D48D, U+D494, U+D4A9, U+D4B0, U+D4CC, U+D4D0, U+D4D4, U+D4DC, + U+D4DF, U+D4E8, U+D4EC, U+D4F0, U+D4F8, U+D4FB, U+D4FD, U+D504, U+D508, U+D50C, + U+D514-D515, U+D517, U+D53C-D53D, U+D540, U+D544, U+D54C-D54D, U+D54F, U+D551, U+D558-D559, U+D55C, + U+D560, U+D565, U+D568-D569, U+D56B, U+D56D, U+D571, U+D573-D575, U+D578, U+D57C, U+D584-D585, + U+D587-D589, U+D58F-D590, U+D59D, U+D5A5, U+D5C8-D5C9, U+D5CC, U+D5D0, U+D5D2, U+D5D8-D5D9, U+D5DB, + U+D5DD, U+D5E4-D5E5, U+D5E8, U+D5EC, U+D5F3-D5F5, U+D5F7, U+D5F9, U+D5FF-D601, U+D604, U+D608, + U+D610-D611, U+D613-D615, U+D61C, U+D620, U+D624, U+D62D, U+D638-D639, U+D63C, U+D640, U+D645, + U+D648-D649, U+D64B, U+D64D, U+D651, U+D654-D655, U+D658, U+D65C, U+D664, U+D667, U+D669, + U+D670-D671, U+D674, U+D683, U+D685, U+D68C-D68D, U+D690, U+D694, U+D69D, U+D69F, U+D6A1, + U+D6A8, U+D6AC, U+D6B0, U+D6B9, U+D6BB, U+D6BD, U+D6C3-D6C5, U+D6C8, U+D6CC, U+D6D1, + U+D6D4-D6D5, U+D6D7, U+D6D9, U+D6E0, U+D6E4, U+D6E8, U+D6F0, U+D6F5, U+D6FC-D6FD, U+D700, + U+D704, U+D711, U+D718-D719, U+D71C, U+D720, U+D728-D729, U+D72B, U+D72D, U+D734-D735, U+D738, + U+D73C, U+D744, U+D747, U+D749, U+D750-D751, U+D754, U+D756-D759, U+D760-D761, U+D763, U+D765, + U+D769, U+D76C, U+D770, U+D774, U+D77C-D77D, U+D781, U+D788-D789, U+D78C, U+D790, U+D798-D799, + U+D79B, U+D79D, U+FF01-FF5E, U+FFE0-FFE3, U+FFE5-FFE6; +} + +/* NanumSquareB.woff2 sha256: c6baca42985691111d4faa6b056277e11f65dd6ea4ed5693b1ea2cd0b7b43f25 */ +@font-face { + font-family: 'NanumSquare'; + src: url('/assets/fonts/NanumSquareB.woff2') format('woff2'); + font-weight: 700; + font-style: normal; + font-display: swap; + unicode-range: + U+20-7E, U+A1, U+A4, U+A7-AB, U+B0-B4, U+B6, U+B8-BF, U+C6, U+D0, U+D7-D8, + U+DE-DF, U+E6, U+F0, U+F7-F8, U+FE, U+111, U+126-127, U+131-133, U+138, U+13F-142, + U+149-14B, U+152-153, U+166-167, U+2C7, U+2D8-2DD, U+391-3A1, U+3A3-3A9, U+3B1-3C1, U+3C3-3C9, U+401, + U+410-44F, U+451, U+2010, U+2013-2014, U+2016, U+2018-2019, U+201C-201D, U+2020-2021, U+2025-2026, U+2030, + U+2032-2033, U+203B, U+2074, U+207F, U+2081-2084, U+20A9, U+2103, U+2109, U+2113, U+2116, + U+2121-2122, U+2126, U+212B, U+2153-2154, U+215B-215E, U+2160-2169, U+2170-2179, U+2190-2199, U+21D2, U+21D4, + U+2200, U+2202-2203, U+2207-2208, U+220B, U+220F, U+2211, U+221A, U+221D-221E, U+2220, U+2227-222C, + U+222E, U+2234-2236, U+223D, U+2252, U+2260-2261, U+2264-2265, U+2282-2283, U+2286-2287, U+22A5, U+22EF, + U+2312, U+2460-246E, U+2474-2482, U+249C-24B5, U+24D0-24E9, U+2500-2503, U+250C-254B, U+2592, U+25A0-25A1, U+25A3-25A9, + U+25B2-25B3, U+25B6-25B7, U+25BC-25BD, U+25C0-25C1, U+25C6-25C9, U+25CB, U+25CE-25D1, U+2605-2606, U+260E-260F, U+261C, + U+261E, U+2640, U+2642, U+2660-2661, U+2663-2665, U+2667-266A, U+266C-266D, U+3000-3003, U+3008-3011, U+3013-3015, + U+3041-3093, U+30A1-30F6, U+30FB, U+3131-318E, U+3200-321C, U+3260-327B, U+327F, U+3380-3384, U+3388-33CA, U+33CF-33D0, + U+33D3, U+33D6, U+33D8, U+33DB-33DD, U+AC00-AC01, U+AC04, U+AC07-AC0A, U+AC10-AC17, U+AC19-AC1D, U+AC20, + U+AC24, U+AC2C-AC2D, U+AC2F-AC31, U+AC38-AC39, U+AC3C, U+AC40, U+AC4B, U+AC4D, U+AC54, U+AC58, + U+AC5C, U+AC70-AC71, U+AC74, U+AC77-AC78, U+AC7A, U+AC80-AC81, U+AC83-AC86, U+AC89-AC8C, U+AC90, U+AC94, + U+AC9C-AC9D, U+AC9F-ACA1, U+ACA8-ACAA, U+ACAC, U+ACAF-ACB0, U+ACB8-ACB9, U+ACBB-ACBD, U+ACC1, U+ACC4, U+ACC8, + U+ACCC, U+ACD5, U+ACD7, U+ACE0-ACE1, U+ACE4, U+ACE7-ACE8, U+ACEA, U+ACEC, U+ACEF-ACF1, U+ACF3, + U+ACF5-ACF6, U+ACFC-ACFD, U+AD00, U+AD04, U+AD06, U+AD0C-AD0D, U+AD0F, U+AD11, U+AD18, U+AD1C, + U+AD1E, U+AD20, U+AD29, U+AD2C-AD2D, U+AD34-AD35, U+AD38, U+AD3C, U+AD44-AD45, U+AD47, U+AD49, + U+AD50, U+AD54, U+AD58, U+AD61, U+AD63, U+AD65, U+AD6C-AD6D, U+AD70, U+AD73-AD76, U+AD7B-AD7D, + U+AD7F, U+AD81-AD82, U+AD88-AD89, U+AD8C, U+AD90, U+AD9C-AD9D, U+ADA4, U+ADB4, U+ADB7, U+ADC0-ADC1, + U+ADC4, U+ADC8, U+ADD0-ADD1, U+ADD3, U+ADDC, U+ADE0, U+ADE4, U+ADF8-ADF9, U+ADFC, U+ADFF-AE01, + U+AE08-AE09, U+AE0B, U+AE0D, U+AE14, U+AE1C, U+AE30-AE31, U+AE34, U+AE37-AE38, U+AE3A, U+AE40-AE41, + U+AE43, U+AE45-AE46, U+AE4A, U+AE4C-AE4E, U+AE50, U+AE54, U+AE56, U+AE5C-AE5D, U+AE5F-AE61, U+AE65, + U+AE68-AE69, U+AE6C, U+AE70, U+AE78-AE79, U+AE7B-AE7D, U+AE84-AE86, U+AE8C-AE8D, U+AEA0, U+AEBC-AEBE, U+AEC0, + U+AEC4, U+AECC-AECD, U+AECF-AED1, U+AED8-AED9, U+AEDC, U+AEE8, U+AEEB, U+AEED, U+AEF4, U+AEF8, + U+AEFC, U+AF07-AF08, U+AF0D, U+AF10, U+AF2C-AF2D, U+AF30, U+AF32, U+AF34, U+AF3C-AF3D, U+AF3F, + U+AF41-AF43, U+AF48-AF49, U+AF50, U+AF5C-AF5D, U+AF64-AF65, U+AF79, U+AF80, U+AF84, U+AF88, U+AF90-AF91, + U+AF95, U+AF9C, U+AFA4, U+AFB8-AFBA, U+AFBC, U+AFC0, U+AFC7-AFC9, U+AFCB, U+AFCD-AFCE, U+AFD4, + U+AFDC, U+AFE8-AFE9, U+AFF0-AFF1, U+AFF4, U+AFF8-AFF9, U+AFFF-B001, U+B004, U+B00C, U+B010, U+B014, + U+B01C-B01D, U+B028, U+B044-B045, U+B048, U+B04A, U+B04C, U+B04E, U+B053-B055, U+B057, U+B059, + U+B05D, U+B068, U+B07C-B07D, U+B080, U+B084, U+B08C-B08D, U+B08F, U+B091, U+B098-B09A, U+B09C, + U+B09F-B0A2, U+B0A8-B0A9, U+B0AB-B0AF, U+B0B1, U+B0B3-B0B5, U+B0B8, U+B0BC, U+B0C4-B0C5, U+B0C7-B0C9, U+B0D0-B0D1, + U+B0D4, U+B0D8, U+B0E0, U+B0E5, U+B108-B109, U+B10B-B10C, U+B110, U+B112-B113, U+B118-B119, U+B11B-B11D, + U+B123-B125, U+B128, U+B12C, U+B134-B135, U+B137-B139, U+B140-B141, U+B144, U+B148, U+B150-B151, U+B154-B155, + U+B158, U+B15C, U+B160, U+B178-B179, U+B17C, U+B180, U+B182, U+B188-B189, U+B18B, U+B18D, + U+B192-B194, U+B198, U+B19C, U+B1A8, U+B1CC, U+B1D0, U+B1D4, U+B1DC-B1DD, U+B1DF, U+B1E8-B1E9, + U+B1EC, U+B1F0, U+B1F9, U+B1FB, U+B1FD, U+B204-B205, U+B208, U+B20B-B20C, U+B214-B215, U+B217, + U+B219, U+B220, U+B234, U+B23C, U+B24C-B24D, U+B251, U+B258, U+B25C, U+B260, U+B268-B269, + U+B274-B275, U+B27C, U+B284-B285, U+B289, U+B290-B291, U+B294, U+B298-B29A, U+B2A0-B2A1, U+B2A3, U+B2A5-B2A6, + U+B2AA, U+B2AC, U+B2B0, U+B2B4, U+B2C8-B2C9, U+B2CC, U+B2D0, U+B2D2, U+B2D8-B2D9, U+B2DB, + U+B2DD, U+B2E2, U+B2E4-B2E6, U+B2E8, U+B2EB-B2EF, U+B2F3-B2F5, U+B2F7-B2FB, U+B2FF-B301, U+B304, U+B308, + U+B310-B311, U+B313-B315, U+B31C, U+B324, U+B32C, U+B331, U+B354-B356, U+B358, U+B35B-B35C, U+B35E-B35F, + U+B364-B365, U+B367, U+B369, U+B36B, U+B36E, U+B370-B371, U+B374, U+B378, U+B380-B381, U+B383-B385, + U+B38C, U+B390, U+B394, U+B3A0-B3A1, U+B3A8, U+B3AC, U+B3C4-B3C5, U+B3C8, U+B3CB-B3CC, U+B3CE, + U+B3D0, U+B3D4-B3D5, U+B3D7, U+B3D9, U+B3DB, U+B3DD, U+B3E0, U+B3E4, U+B3E8, U+B3FC, + U+B400, U+B40F-B411, U+B418, U+B41C, U+B420, U+B428-B429, U+B42B-B42D, U+B434, U+B450-B451, U+B454, + U+B458, U+B460-B461, U+B463, U+B465, U+B46C, U+B480, U+B488, U+B49D, U+B4A4, U+B4A8, + U+B4AC, U+B4B5, U+B4B7, U+B4B9, U+B4C0, U+B4C4, U+B4C8, U+B4D0, U+B4D5, U+B4DC-B4DD, + U+B4E0, U+B4E3-B4E4, U+B4E6, U+B4EC-B4ED, U+B4EF, U+B4F1, U+B4F8, U+B514-B515, U+B518, U+B51B-B51C, + U+B524-B525, U+B527-B52A, U+B530-B531, U+B534, U+B538, U+B540-B541, U+B543-B545, U+B54B-B54D, U+B550, U+B554, + U+B55C-B55D, U+B55F-B561, U+B568, U+B584, U+B599, U+B5A0-B5A1, U+B5A4, U+B5A8, U+B5AA-B5AB, U+B5B0-B5B1, + U+B5B3-B5B5, U+B5BB-B5BD, U+B5C0, U+B5C4, U+B5CC-B5CD, U+B5CF-B5D1, U+B5D8, U+B5EC, U+B610-B611, U+B614, + U+B618, U+B620, U+B625, U+B62C, U+B634, U+B648, U+B65D, U+B664, U+B668, U+B69C-B69D, + U+B6A0, U+B6A4, U+B6AB-B6AC, U+B6B1, U+B6D4, U+B6F0, U+B6F4, U+B6F8, U+B700-B701, U+B705, + U+B71B, U+B728-B729, U+B72C, U+B72F-B730, U+B738-B739, U+B73B, U+B73D, U+B744, U+B748, U+B74C, + U+B754-B755, U+B760-B761, U+B764, U+B768, U+B770-B771, U+B773, U+B775, U+B77C-B77D, U+B780, U+B784, + U+B78C-B78D, U+B78F-B792, U+B796-B799, U+B79C, U+B7A0, U+B7A8-B7A9, U+B7AB-B7AD, U+B7B4-B7B5, U+B7B8, U+B7BC, + U+B7C7, U+B7C9, U+B7EC-B7ED, U+B7F0, U+B7F4, U+B7FC-B7FD, U+B7FF-B801, U+B807-B809, U+B80C, U+B810, + U+B818-B819, U+B81B, U+B81D, U+B824-B825, U+B828, U+B82C, U+B834-B835, U+B837-B839, U+B840, U+B844, + U+B851, U+B853, U+B85C-B85D, U+B860, U+B864, U+B86C-B86D, U+B86F, U+B871, U+B878-B879, U+B87C, + U+B880, U+B88D, U+B894, U+B8A8, U+B8B0, U+B8B4, U+B8B8, U+B8C0-B8C1, U+B8C3, U+B8C5, + U+B8CC, U+B8D0, U+B8D4, U+B8DD, U+B8DF, U+B8E1, U+B8E8-B8E9, U+B8EC, U+B8F0, U+B8F8-B8F9, + U+B8FB, U+B8FD, U+B904, U+B918, U+B920, U+B93C-B93D, U+B940, U+B944, U+B94C, U+B94F, + U+B951, U+B958-B959, U+B95C, U+B960, U+B968-B969, U+B96B, U+B96D, U+B974-B975, U+B978, U+B97C, + U+B984-B985, U+B987, U+B989-B98A, U+B98D-B98E, U+B9AC-B9AD, U+B9B0, U+B9B4, U+B9BC-B9BD, U+B9BF, U+B9C1, + U+B9C8-B9C9, U+B9CC, U+B9CE-B9D2, U+B9D8-B9D9, U+B9DB, U+B9DD-B9DE, U+B9E1, U+B9E3-B9E5, U+B9E8, U+B9EC, + U+B9F4-B9F5, U+B9F7-B9FA, U+BA00-BA01, U+BA04, U+BA08, U+BA15, U+BA38-BA39, U+BA3C, U+BA40, U+BA42, + U+BA48-BA49, U+BA4B, U+BA4D-BA4E, U+BA53-BA55, U+BA58, U+BA5C, U+BA64-BA65, U+BA67-BA69, U+BA70-BA71, U+BA74, + U+BA78, U+BA83-BA85, U+BA87, U+BA8C, U+BAA8-BAA9, U+BAAB-BAAC, U+BAB0, U+BAB2, U+BAB8-BAB9, U+BABB, + U+BABD, U+BAC4, U+BAC8, U+BAD8-BAD9, U+BAFC, U+BB00, U+BB04, U+BB0D, U+BB0F, U+BB11, + U+BB18, U+BB1C, U+BB20, U+BB29, U+BB2B, U+BB34-BB36, U+BB38, U+BB3B-BB3E, U+BB44-BB45, U+BB47, + U+BB49, U+BB4D, U+BB4F-BB50, U+BB54, U+BB58, U+BB61, U+BB63, U+BB65, U+BB6C, U+BB88, + U+BB8C, U+BB90, U+BBA4, U+BBA8, U+BBAC, U+BBB4, U+BBB7, U+BBC0, U+BBC4, U+BBC8, + U+BBD0, U+BBD3, U+BBDC, U+BBF8-BBF9, U+BBFC, U+BBFF-BC00, U+BC02, U+BC08-BC09, U+BC0B-BC0D, U+BC0F, + U+BC11, U+BC14-BC18, U+BC1B-BC1F, U+BC24-BC25, U+BC27, U+BC29, U+BC2D, U+BC30-BC31, U+BC34, U+BC38, + U+BC40-BC41, U+BC43-BC45, U+BC49, U+BC4C-BC4D, U+BC50, U+BC5D, U+BC84-BC85, U+BC88, U+BC8B-BC8C, U+BC8E, + U+BC94-BC95, U+BC97, U+BC99-BC9A, U+BCA0-BCA1, U+BCA4, U+BCA7-BCA9, U+BCB0-BCB1, U+BCB3-BCB5, U+BCBC-BCBD, U+BCC0, + U+BCC4, U+BCCD, U+BCCF-BCD1, U+BCD5, U+BCD8, U+BCDC, U+BCF4-BCF6, U+BCF8, U+BCFC, U+BD04-BD05, + U+BD07, U+BD09, U+BD10, U+BD14, U+BD23-BD25, U+BD2C, U+BD35, U+BD40, U+BD48-BD49, U+BD4C, + U+BD50, U+BD58-BD59, U+BD64, U+BD68, U+BD79, U+BD80-BD81, U+BD84, U+BD87-BD8A, U+BD90-BD91, U+BD93, + U+BD95, U+BD99-BD9A, U+BD9C, U+BDA4, U+BDB0, U+BDB8-BDB9, U+BDC1, U+BDD4-BDD5, U+BDD8, U+BDDC, + U+BDE9, U+BDF0, U+BDF4, U+BDF8, U+BE00, U+BE03, U+BE05, U+BE0C-BE0D, U+BE10, U+BE14, + U+BE1C-BE1D, U+BE1F, U+BE44-BE45, U+BE48, U+BE4C, U+BE4E, U+BE54-BE55, U+BE57, U+BE59-BE5B, U+BE60-BE61, + U+BE64, U+BE68, U+BE6A, U+BE70-BE71, U+BE73-BE75, U+BE7B-BE7D, U+BE80, U+BE84, U+BE8C-BE8D, U+BE8F-BE91, + U+BE98-BE99, U+BEA8, U+BEAD, U+BEB4, U+BED0-BED1, U+BED4, U+BED7-BED8, U+BEE0, U+BEE3-BEE5, U+BEEC, + U+BEF5, U+BF01, U+BF08-BF09, U+BF18-BF19, U+BF1B-BF1D, U+BF40-BF41, U+BF44, U+BF48, U+BF50-BF51, U+BF55, + U+BF5C, U+BF71, U+BF94, U+BFB0, U+BFB8, U+BFC5, U+BFCC-BFCD, U+BFD0, U+BFD4, U+BFDC, + U+BFDF, U+BFE1, U+C00D, U+C03C, U+C04C, U+C051, U+C058, U+C05C, U+C060, U+C068-C069, + U+C090-C091, U+C094, U+C098, U+C0A0-C0A1, U+C0A3, U+C0A5, U+C0AC-C0AD, U+C0AF-C0B0, U+C0B3-C0B6, U+C0BC-C0BD, + U+C0BF-C0C1, U+C0C5, U+C0C8-C0C9, U+C0CC, U+C0D0, U+C0D8-C0D9, U+C0DB-C0DD, U+C0E4-C0E5, U+C0E8, U+C0EC, + U+C0F4-C0F5, U+C0F7, U+C0F9, U+C0FE, U+C100, U+C104, U+C108, U+C110, U+C115, U+C11C-C120, + U+C123-C124, U+C126-C127, U+C12C-C12D, U+C12F-C131, U+C136, U+C138-C139, U+C13C, U+C140, U+C142, U+C148-C149, + U+C14B-C14D, U+C154-C155, U+C158, U+C15C, U+C164-C165, U+C167-C169, U+C170, U+C174, U+C178, U+C185, + U+C18C-C18E, U+C190, U+C194, U+C196, U+C19C-C19D, U+C19F, U+C1A1, U+C1A5, U+C1A8-C1A9, U+C1AC, + U+C1B0, U+C1BD, U+C1C4, U+C1C8, U+C1CC, U+C1D4, U+C1D7-C1D8, U+C1E0, U+C1E4, U+C1E8, + U+C1F0-C1F1, U+C1F3, U+C1FC-C1FD, U+C200, U+C204, U+C20C-C20D, U+C20F, U+C211, U+C216, U+C218-C219, + U+C21C, U+C21F-C220, U+C228-C229, U+C22B, U+C22D, U+C22F, U+C231-C232, U+C234, U+C245, U+C248, + U+C250-C251, U+C254, U+C258, U+C25F-C260, U+C265, U+C26C-C26D, U+C270, U+C274, U+C27C-C27D, U+C27F, + U+C281, U+C288-C289, U+C28C, U+C290, U+C298-C299, U+C29B, U+C29D, U+C2A0, U+C2A4-C2A5, U+C2A8, + U+C2AC-C2AD, U+C2B4-C2B5, U+C2B7, U+C2B9, U+C2DC-C2DD, U+C2E0, U+C2E3-C2E4, U+C2EB-C2ED, U+C2EF, U+C2F1, + U+C2F6, U+C2F8-C2F9, U+C2FB-C2FC, U+C300, U+C308-C309, U+C30C-C30D, U+C313-C315, U+C318, U+C31C, U+C324-C325, + U+C328-C329, U+C330, U+C338, U+C345, U+C34C, U+C368-C369, U+C36C, U+C370, U+C372, U+C378-C379, + U+C37B-C37D, U+C384, U+C388, U+C38C, U+C395, U+C399, U+C3B5, U+C3BC, U+C3C0, U+C3D8-C3D9, + U+C3DC, U+C3DF-C3E0, U+C3E2, U+C3E8-C3E9, U+C3ED, U+C3F4-C3F5, U+C3F8, U+C3FC, U+C408, U+C410, + U+C424, U+C42C, U+C430, U+C434, U+C43C-C43D, U+C448, U+C450, U+C45D, U+C464-C465, U+C468, + U+C46C, U+C474-C475, U+C479, U+C480, U+C494, U+C49C-C49D, U+C4B8, U+C4BC, U+C4CD, U+C4D4, + U+C4D8, U+C4E9, U+C4F0-C4F1, U+C4F4, U+C4F8, U+C4FA, U+C4FF-C501, U+C50C, U+C510, U+C514, + U+C51C, U+C521, U+C528-C529, U+C52C, U+C530, U+C538-C539, U+C53B, U+C53D, U+C544-C545, U+C548-C54A, + U+C54C-C54E, U+C553-C555, U+C557-C559, U+C55C-C55E, U+C560-C561, U+C564, U+C568, U+C570-C571, U+C573-C575, U+C57B-C57D, + U+C580, U+C584, U+C587, U+C58C-C58D, U+C58F, U+C591, U+C595, U+C597-C598, U+C59C, U+C5A0, + U+C5A9, U+C5B4-C5B5, U+C5B8-C5B9, U+C5BB-C5BE, U+C5C4-C5CA, U+C5CC, U+C5CE, U+C5D0-C5D1, U+C5D4, U+C5D8, + U+C5E0-C5E1, U+C5E3, U+C5E5, U+C5EC-C5EE, U+C5F0, U+C5F4, U+C5F6-C5F7, U+C5FC-C601, U+C605-C608, U+C60C, + U+C610, U+C618-C619, U+C61B-C61C, U+C624-C625, U+C628, U+C62C-C62E, U+C630, U+C633-C635, U+C637, U+C639, + U+C63B, U+C640-C641, U+C644, U+C648, U+C650-C651, U+C653-C655, U+C65C-C65D, U+C660, U+C664, U+C66C, + U+C66F, U+C671, U+C674, U+C678-C679, U+C67C, U+C680, U+C688-C689, U+C68B, U+C68D, U+C694-C695, + U+C698, U+C69C, U+C6A4-C6A5, U+C6A7, U+C6A9, U+C6B0-C6B1, U+C6B4, U+C6B8-C6BA, U+C6C0-C6C1, U+C6C3, + U+C6C5, U+C6CC-C6CD, U+C6D0, U+C6D4, U+C6DC-C6DD, U+C6DF-C6E1, U+C6E8-C6E9, U+C6EC, U+C6F0-C6F1, U+C6F8-C6F9, + U+C6FD, U+C704-C705, U+C708, U+C70C, U+C714-C715, U+C717, U+C719, U+C720-C721, U+C724, U+C728, + U+C730-C731, U+C733, U+C735, U+C737, U+C73C-C73E, U+C740, U+C743-C744, U+C74A, U+C74C-C74F, U+C751-C758, + U+C75C, U+C760, U+C768, U+C76B, U+C774-C776, U+C778, U+C77C-C77E, U+C783-C785, U+C787-C78A, U+C78C, + U+C78E, U+C790-C791, U+C794, U+C796-C798, U+C79A, U+C7A0-C7A1, U+C7A3-C7A6, U+C7AC-C7AD, U+C7B0, U+C7B4, + U+C7BC-C7BD, U+C7BF-C7C1, U+C7C8-C7C9, U+C7CC, U+C7CE, U+C7D0, U+C7D8-C7D9, U+C7DD, U+C7E4, U+C7E8, + U+C7EC, U+C800-C801, U+C804, U+C808, U+C80A, U+C810-C811, U+C813, U+C815-C816, U+C81C-C81D, U+C820, + U+C824, U+C82C-C82D, U+C82F, U+C831, U+C838, U+C83C, U+C840, U+C848-C849, U+C84C-C84D, U+C854, + U+C870-C871, U+C874, U+C878, U+C87A, U+C880-C881, U+C883, U+C885-C887, U+C88B-C88D, U+C890, U+C894, + U+C89D, U+C89F, U+C8A1, U+C8A8, U+C8BC-C8BD, U+C8C4, U+C8C8, U+C8CC, U+C8D4-C8D5, U+C8D7, + U+C8D9, U+C8E0-C8E1, U+C8E4, U+C8E8, U+C8F5, U+C8FC-C8FD, U+C900, U+C904-C906, U+C90C-C90D, U+C90F, + U+C911, U+C918, U+C92C, U+C934-C935, U+C938, U+C950-C951, U+C954, U+C958, U+C960-C961, U+C963, + U+C965, U+C96C, U+C970, U+C974, U+C97C, U+C981, U+C988-C989, U+C98C, U+C990, U+C998-C999, + U+C99B, U+C99D, U+C9A4, U+C9A8, U+C9C0-C9C1, U+C9C4, U+C9C7-C9C8, U+C9CA, U+C9D0-C9D1, U+C9D3, + U+C9D5-C9D6, U+C9D9-C9DA, U+C9DC-C9DD, U+C9E0, U+C9E2, U+C9E4, U+C9E7, U+C9EC-C9ED, U+C9EF-C9F1, U+C9F8-C9F9, + U+C9FC, U+CA00, U+CA08-CA09, U+CA0B-CA0D, U+CA14, U+CA18, U+CA1C, U+CA29, U+CA30, U+CA4C-CA4D, + U+CA50, U+CA54, U+CA5C-CA5D, U+CA5F-CA61, U+CA68, U+CA7D, U+CA84, U+CA98, U+CABC-CABD, U+CAC0, + U+CAC4, U+CACC-CACD, U+CACF, U+CAD1, U+CAD3, U+CAD8-CAD9, U+CAE0, U+CAEC, U+CAF4, U+CB08, + U+CB10, U+CB14, U+CB18, U+CB20-CB21, U+CB2C, U+CB41, U+CB48-CB49, U+CB4C, U+CB50, U+CB58-CB59, + U+CB5D, U+CB64, U+CB78-CB79, U+CB9C, U+CBB8, U+CBD4, U+CBE4-CBE5, U+CBE7, U+CBE9, U+CC0C-CC0D, + U+CC10, U+CC14, U+CC1C-CC1D, U+CC21-CC22, U+CC27-CC29, U+CC2C, U+CC2E, U+CC30, U+CC38-CC39, U+CC3B-CC3E, + U+CC44-CC45, U+CC48, U+CC4C, U+CC54-CC55, U+CC57-CC59, U+CC60, U+CC64, U+CC66, U+CC68, U+CC70, + U+CC75, U+CC98-CC99, U+CC9C, U+CCA0, U+CCA8-CCA9, U+CCAB-CCAD, U+CCB4-CCB5, U+CCB8, U+CCBC, U+CCC4-CCC5, + U+CCC7, U+CCC9, U+CCD0, U+CCD4, U+CCE1, U+CCE4, U+CCEC, U+CCF0, U+CD01, U+CD08-CD09, + U+CD0C, U+CD10, U+CD18-CD19, U+CD1B, U+CD1D, U+CD24, U+CD28, U+CD2C, U+CD39, U+CD5C-CD5D, + U+CD60, U+CD64, U+CD6C-CD6D, U+CD6F, U+CD71, U+CD78, U+CD88, U+CD94-CD95, U+CD98, U+CD9C, + U+CDA4-CDA5, U+CDA7, U+CDA9, U+CDB0, U+CDC4, U+CDCC-CDCD, U+CDD0, U+CDDD, U+CDDF, U+CDE8, + U+CDEC, U+CDF0, U+CDF8-CDF9, U+CDFB, U+CDFD, U+CE04, U+CE08, U+CE0C, U+CE14-CE15, U+CE19, + U+CE20-CE21, U+CE24, U+CE28, U+CE30-CE31, U+CE33, U+CE35, U+CE58-CE59, U+CE5C, U+CE5F-CE61, U+CE68-CE69, + U+CE6B, U+CE6D, U+CE74-CE75, U+CE78, U+CE7C, U+CE84-CE85, U+CE87, U+CE89, U+CE8C, U+CE90-CE91, + U+CE94, U+CE98, U+CEA0-CEA1, U+CEA3-CEA5, U+CEAC-CEAE, U+CEB4, U+CEC1, U+CEC4, U+CEE4-CEE5, U+CEE8, + U+CEEB-CEEC, U+CEF4-CEF5, U+CEF7-CEF9, U+CF00-CF01, U+CF04, U+CF08, U+CF10-CF11, U+CF13, U+CF15, U+CF18, + U+CF1C, U+CF20, U+CF24, U+CF2C-CF2D, U+CF2F-CF31, U+CF38, U+CF54-CF55, U+CF58, U+CF5C, U+CF64-CF65, + U+CF67, U+CF69, U+CF70-CF71, U+CF74, U+CF78, U+CF80, U+CF85, U+CF8C, U+CFA1, U+CFA8, + U+CFB0, U+CFC4, U+CFDC, U+CFE0-CFE1, U+CFE4, U+CFE8, U+CFF0-CFF1, U+CFF3, U+CFF5, U+CFFC-CFFD, + U+D000, U+D004, U+D011, U+D018, U+D02D, U+D034-D035, U+D038, U+D03C, U+D044-D045, U+D047, + U+D049, U+D050, U+D054, U+D058, U+D060, U+D06C-D06E, U+D070, U+D074, U+D07C-D07D, U+D07F, + U+D081, U+D084, U+D0A4-D0A5, U+D0A8, U+D0AC, U+D0B4-D0B5, U+D0B7, U+D0B9, U+D0C0-D0C1, U+D0C4, + U+D0C8-D0C9, U+D0D0-D0D1, U+D0D3-D0D5, U+D0DC-D0DD, U+D0E0, U+D0E4, U+D0EC-D0ED, U+D0EF-D0F1, U+D0F8, U+D10D, + U+D130-D131, U+D134, U+D138, U+D13A, U+D140-D141, U+D143-D145, U+D14C-D14D, U+D150, U+D154, U+D15C-D15D, + U+D15F, U+D161, U+D168, U+D16C, U+D17C, U+D184, U+D188, U+D1A0-D1A1, U+D1A4, U+D1A8, + U+D1B0-D1B1, U+D1B3, U+D1B5, U+D1BA, U+D1BC, U+D1C0, U+D1D8, U+D1F4, U+D1F8, U+D207, + U+D209, U+D210, U+D22C-D22D, U+D230, U+D234, U+D23C-D23D, U+D23F, U+D241, U+D248, U+D25C, + U+D264, U+D277, U+D280-D281, U+D284, U+D288, U+D290-D291, U+D295, U+D29C, U+D2A0, U+D2A4, + U+D2AC, U+D2B1, U+D2B8-D2B9, U+D2BC, U+D2BF-D2C0, U+D2C2, U+D2C8-D2C9, U+D2CB, U+D2D4, U+D2D8, + U+D2DC, U+D2E4-D2E5, U+D2F0-D2F1, U+D2F4, U+D2F8, U+D300-D301, U+D303, U+D305, U+D30C-D30E, U+D310, + U+D314, U+D316, U+D31C-D31D, U+D31F-D321, U+D325, U+D328-D329, U+D32C, U+D330, U+D338-D339, U+D33B-D33D, + U+D344-D345, U+D37C-D37D, U+D380, U+D384, U+D38C-D38D, U+D38F-D391, U+D398-D399, U+D39C, U+D3A0, U+D3A8-D3A9, + U+D3AB, U+D3AD, U+D3B4, U+D3B8, U+D3BC, U+D3C4-D3C5, U+D3C8-D3C9, U+D3D0, U+D3D8, U+D3E1, + U+D3E3, U+D3EC-D3ED, U+D3F0, U+D3F4, U+D3FC-D3FD, U+D3FF, U+D401, U+D408, U+D41D, U+D440, + U+D444, U+D45C, U+D460, U+D464, U+D46D, U+D46F, U+D478-D479, U+D47C, U+D47F-D480, U+D482, + U+D488-D489, U+D48B, U+D48D, U+D494, U+D4A9, U+D4B0, U+D4CC, U+D4D0, U+D4D4, U+D4DC, + U+D4DF, U+D4E8, U+D4EC, U+D4F0, U+D4F8, U+D4FB, U+D4FD, U+D504, U+D508, U+D50C, + U+D514-D515, U+D517, U+D53C-D53D, U+D540, U+D544, U+D54C-D54D, U+D54F, U+D551, U+D558-D559, U+D55C, + U+D560, U+D565, U+D568-D569, U+D56B, U+D56D, U+D571, U+D573-D575, U+D578, U+D57C, U+D584-D585, + U+D587-D589, U+D58F-D590, U+D59D, U+D5A5, U+D5C8-D5C9, U+D5CC, U+D5D0, U+D5D2, U+D5D8-D5D9, U+D5DB, + U+D5DD, U+D5E4-D5E5, U+D5E8, U+D5EC, U+D5F3-D5F5, U+D5F7, U+D5F9, U+D5FF-D601, U+D604, U+D608, + U+D610-D611, U+D613-D615, U+D61C, U+D620, U+D624, U+D62D, U+D638-D639, U+D63C, U+D640, U+D645, + U+D648-D649, U+D64B, U+D64D, U+D651, U+D654-D655, U+D658, U+D65C, U+D664, U+D667, U+D669, + U+D670-D671, U+D674, U+D683, U+D685, U+D68C-D68D, U+D690, U+D694, U+D69D, U+D69F, U+D6A1, + U+D6A8, U+D6AC, U+D6B0, U+D6B9, U+D6BB, U+D6BD, U+D6C3-D6C5, U+D6C8, U+D6CC, U+D6D1, + U+D6D4-D6D5, U+D6D7, U+D6D9, U+D6E0, U+D6E4, U+D6E8, U+D6F0, U+D6F5, U+D6FC-D6FD, U+D700, + U+D704, U+D711, U+D718-D719, U+D71C, U+D720, U+D728-D729, U+D72B, U+D72D, U+D734-D735, U+D738, + U+D73C, U+D744, U+D747, U+D749, U+D750-D751, U+D754, U+D756-D759, U+D760-D761, U+D763, U+D765, + U+D769, U+D76C, U+D770, U+D774, U+D77C-D77D, U+D781, U+D788-D789, U+D78C, U+D790, U+D798-D799, + U+D79B, U+D79D, U+FF01-FF5E, U+FFE0-FFE3, U+FFE5-FFE6; +} + +/* NanumSquareEB.woff2 sha256: 8b8bda555af380ee62435880cb302af570a4c0364a3842fdb79b847ee220fb33 */ +@font-face { + font-family: 'NanumSquare'; + src: url('/assets/fonts/NanumSquareEB.woff2') format('woff2'); + font-weight: 800; + font-style: normal; + font-display: swap; + unicode-range: + U+20-7E, U+A1, U+A4, U+A7-AB, U+B0-B4, U+B6, U+B8-BF, U+C6, U+D0, U+D7-D8, + U+DE-DF, U+E6, U+F0, U+F7-F8, U+FE, U+111, U+126-127, U+131-133, U+138, U+13F-142, + U+149-14B, U+152-153, U+166-167, U+2C7, U+2D8-2DD, U+391-3A1, U+3A3-3A9, U+3B1-3C1, U+3C3-3C9, U+401, + U+410-44F, U+451, U+2010, U+2013-2014, U+2016, U+2018-2019, U+201C-201D, U+2020-2021, U+2025-2026, U+2030, + U+2032-2033, U+203B, U+2074, U+207F, U+2081-2084, U+20A9, U+2103, U+2109, U+2113, U+2116, + U+2121-2122, U+2126, U+212B, U+2153-2154, U+215B-215E, U+2160-2169, U+2170-2179, U+2190-2199, U+21D2, U+21D4, + U+2200, U+2202-2203, U+2207-2208, U+220B, U+220F, U+2211, U+221A, U+221D-221E, U+2220, U+2227-222C, + U+222E, U+2234-2236, U+223D, U+2252, U+2260-2261, U+2264-2265, U+2282-2283, U+2286-2287, U+22A5, U+22EF, + U+2312, U+2460-246E, U+2474-2482, U+249C-24B5, U+24D0-24E9, U+2500-2503, U+250C-254B, U+2592, U+25A0-25A1, U+25A3-25A9, + U+25B2-25B3, U+25B6-25B7, U+25BC-25BD, U+25C0-25C1, U+25C6-25C9, U+25CB, U+25CE-25D1, U+2605-2606, U+260E-260F, U+261C, + U+261E, U+2640, U+2642, U+2660-2661, U+2663-2665, U+2667-266A, U+266C-266D, U+3000-3003, U+3008-3011, U+3013-3015, + U+3041-3093, U+30A1-30F6, U+30FB, U+3131-318E, U+3200-321C, U+3260-327B, U+327F, U+3380-3384, U+3388-33CA, U+33CF-33D0, + U+33D3, U+33D6, U+33D8, U+33DB-33DD, U+AC00-AC01, U+AC04, U+AC07-AC0A, U+AC10-AC17, U+AC19-AC1D, U+AC20, + U+AC24, U+AC2C-AC2D, U+AC2F-AC31, U+AC38-AC39, U+AC3C, U+AC40, U+AC4B, U+AC4D, U+AC54, U+AC58, + U+AC5C, U+AC70-AC71, U+AC74, U+AC77-AC78, U+AC7A, U+AC80-AC81, U+AC83-AC86, U+AC89-AC8C, U+AC90, U+AC94, + U+AC9C-AC9D, U+AC9F-ACA1, U+ACA8-ACAA, U+ACAC, U+ACAF-ACB0, U+ACB8-ACB9, U+ACBB-ACBD, U+ACC1, U+ACC4, U+ACC8, + U+ACCC, U+ACD5, U+ACD7, U+ACE0-ACE1, U+ACE4, U+ACE7-ACE8, U+ACEA, U+ACEC, U+ACEF-ACF1, U+ACF3, + U+ACF5-ACF6, U+ACFC-ACFD, U+AD00, U+AD04, U+AD06, U+AD0C-AD0D, U+AD0F, U+AD11, U+AD18, U+AD1C, + U+AD1E, U+AD20, U+AD29, U+AD2C-AD2D, U+AD34-AD35, U+AD38, U+AD3C, U+AD44-AD45, U+AD47, U+AD49, + U+AD50, U+AD54, U+AD58, U+AD61, U+AD63, U+AD65, U+AD6C-AD6D, U+AD70, U+AD73-AD76, U+AD7B-AD7D, + U+AD7F, U+AD81-AD82, U+AD88-AD89, U+AD8C, U+AD90, U+AD9C-AD9D, U+ADA4, U+ADB4, U+ADB7, U+ADC0-ADC1, + U+ADC4, U+ADC8, U+ADD0-ADD1, U+ADD3, U+ADDC, U+ADE0, U+ADE4, U+ADF8-ADF9, U+ADFC, U+ADFF-AE01, + U+AE08-AE09, U+AE0B, U+AE0D, U+AE14, U+AE1C, U+AE30-AE31, U+AE34, U+AE37-AE38, U+AE3A, U+AE40-AE41, + U+AE43, U+AE45-AE46, U+AE4A, U+AE4C-AE4E, U+AE50, U+AE54, U+AE56, U+AE5C-AE5D, U+AE5F-AE61, U+AE65, + U+AE68-AE69, U+AE6C, U+AE70, U+AE78-AE79, U+AE7B-AE7D, U+AE84-AE86, U+AE8C-AE8D, U+AEA0, U+AEBC-AEBE, U+AEC0, + U+AEC4, U+AECC-AECD, U+AECF-AED1, U+AED8-AED9, U+AEDC, U+AEE8, U+AEEB, U+AEED, U+AEF4, U+AEF8, + U+AEFC, U+AF07-AF08, U+AF0D, U+AF10, U+AF2C-AF2D, U+AF30, U+AF32, U+AF34, U+AF3C-AF3D, U+AF3F, + U+AF41-AF43, U+AF48-AF49, U+AF50, U+AF5C-AF5D, U+AF64-AF65, U+AF79, U+AF80, U+AF84, U+AF88, U+AF90-AF91, + U+AF95, U+AF9C, U+AFA4, U+AFB8-AFBA, U+AFBC, U+AFC0, U+AFC7-AFC9, U+AFCB, U+AFCD-AFCE, U+AFD4, + U+AFDC, U+AFE8-AFE9, U+AFF0-AFF1, U+AFF4, U+AFF8-AFF9, U+AFFF-B001, U+B004, U+B00C, U+B010, U+B014, + U+B01C-B01D, U+B028, U+B044-B045, U+B048, U+B04A, U+B04C, U+B04E, U+B053-B055, U+B057, U+B059, + U+B05D, U+B068, U+B07C-B07D, U+B080, U+B084, U+B08C-B08D, U+B08F, U+B091, U+B098-B09A, U+B09C, + U+B09F-B0A2, U+B0A8-B0A9, U+B0AB-B0AF, U+B0B1, U+B0B3-B0B5, U+B0B8, U+B0BC, U+B0C4-B0C5, U+B0C7-B0C9, U+B0D0-B0D1, + U+B0D4, U+B0D8, U+B0E0, U+B0E5, U+B108-B109, U+B10B-B10C, U+B110, U+B112-B113, U+B118-B119, U+B11B-B11D, + U+B123-B125, U+B128, U+B12C, U+B134-B135, U+B137-B139, U+B140-B141, U+B144, U+B148, U+B150-B151, U+B154-B155, + U+B158, U+B15C, U+B160, U+B178-B179, U+B17C, U+B180, U+B182, U+B188-B189, U+B18B, U+B18D, + U+B192-B194, U+B198, U+B19C, U+B1A8, U+B1CC, U+B1D0, U+B1D4, U+B1DC-B1DD, U+B1DF, U+B1E8-B1E9, + U+B1EC, U+B1F0, U+B1F9, U+B1FB, U+B1FD, U+B204-B205, U+B208, U+B20B-B20C, U+B214-B215, U+B217, + U+B219, U+B220, U+B234, U+B23C, U+B24C-B24D, U+B251, U+B258, U+B25C, U+B260, U+B268-B269, + U+B274-B275, U+B27C, U+B284-B285, U+B289, U+B290-B291, U+B294, U+B298-B29A, U+B2A0-B2A1, U+B2A3, U+B2A5-B2A6, + U+B2AA, U+B2AC, U+B2B0, U+B2B4, U+B2C8-B2C9, U+B2CC, U+B2D0, U+B2D2, U+B2D8-B2D9, U+B2DB, + U+B2DD, U+B2E2, U+B2E4-B2E6, U+B2E8, U+B2EB-B2EF, U+B2F3-B2F5, U+B2F7-B2FB, U+B2FF-B301, U+B304, U+B308, + U+B310-B311, U+B313-B315, U+B31C, U+B324, U+B32C, U+B331, U+B354-B356, U+B358, U+B35B-B35C, U+B35E-B35F, + U+B364-B365, U+B367, U+B369, U+B36B, U+B36E, U+B370-B371, U+B374, U+B378, U+B380-B381, U+B383-B385, + U+B38C, U+B390, U+B394, U+B3A0-B3A1, U+B3A8, U+B3AC, U+B3C4-B3C5, U+B3C8, U+B3CB-B3CC, U+B3CE, + U+B3D0, U+B3D4-B3D5, U+B3D7, U+B3D9, U+B3DB, U+B3DD, U+B3E0, U+B3E4, U+B3E8, U+B3FC, + U+B400, U+B40F-B411, U+B418, U+B41C, U+B420, U+B428-B429, U+B42B-B42D, U+B434, U+B450-B451, U+B454, + U+B458, U+B460-B461, U+B463, U+B465, U+B46C, U+B480, U+B488, U+B49D, U+B4A4, U+B4A8, + U+B4AC, U+B4B5, U+B4B7, U+B4B9, U+B4C0, U+B4C4, U+B4C8, U+B4D0, U+B4D5, U+B4DC-B4DD, + U+B4E0, U+B4E3-B4E4, U+B4E6, U+B4EC-B4ED, U+B4EF, U+B4F1, U+B4F8, U+B514-B515, U+B518, U+B51B-B51C, + U+B524-B525, U+B527-B52A, U+B530-B531, U+B534, U+B538, U+B540-B541, U+B543-B545, U+B54B-B54D, U+B550, U+B554, + U+B55C-B55D, U+B55F-B561, U+B568, U+B584, U+B599, U+B5A0-B5A1, U+B5A4, U+B5A8, U+B5AA-B5AB, U+B5B0-B5B1, + U+B5B3-B5B5, U+B5BB-B5BD, U+B5C0, U+B5C4, U+B5CC-B5CD, U+B5CF-B5D1, U+B5D8, U+B5EC, U+B610-B611, U+B614, + U+B618, U+B620, U+B625, U+B62C, U+B634, U+B648, U+B65D, U+B664, U+B668, U+B69C-B69D, + U+B6A0, U+B6A4, U+B6AB-B6AC, U+B6B1, U+B6D4, U+B6F0, U+B6F4, U+B6F8, U+B700-B701, U+B705, + U+B71B, U+B728-B729, U+B72C, U+B72F-B730, U+B738-B739, U+B73B, U+B73D, U+B744, U+B748, U+B74C, + U+B754-B755, U+B760-B761, U+B764, U+B768, U+B770-B771, U+B773, U+B775, U+B77C-B77D, U+B780, U+B784, + U+B78C-B78D, U+B78F-B792, U+B796-B799, U+B79C, U+B7A0, U+B7A8-B7A9, U+B7AB-B7AD, U+B7B4-B7B5, U+B7B8, U+B7BC, + U+B7C7, U+B7C9, U+B7EC-B7ED, U+B7F0, U+B7F4, U+B7FC-B7FD, U+B7FF-B801, U+B807-B809, U+B80C, U+B810, + U+B818-B819, U+B81B, U+B81D, U+B824-B825, U+B828, U+B82C, U+B834-B835, U+B837-B839, U+B840, U+B844, + U+B851, U+B853, U+B85C-B85D, U+B860, U+B864, U+B86C-B86D, U+B86F, U+B871, U+B878-B879, U+B87C, + U+B880, U+B88D, U+B894, U+B8A8, U+B8B0, U+B8B4, U+B8B8, U+B8C0-B8C1, U+B8C3, U+B8C5, + U+B8CC, U+B8D0, U+B8D4, U+B8DD, U+B8DF, U+B8E1, U+B8E8-B8E9, U+B8EC, U+B8F0, U+B8F8-B8F9, + U+B8FB, U+B8FD, U+B904, U+B918, U+B920, U+B93C-B93D, U+B940, U+B944, U+B94C, U+B94F, + U+B951, U+B958-B959, U+B95C, U+B960, U+B968-B969, U+B96B, U+B96D, U+B974-B975, U+B978, U+B97C, + U+B984-B985, U+B987, U+B989-B98A, U+B98D-B98E, U+B9AC-B9AD, U+B9B0, U+B9B4, U+B9BC-B9BD, U+B9BF, U+B9C1, + U+B9C8-B9C9, U+B9CC, U+B9CE-B9D2, U+B9D8-B9D9, U+B9DB, U+B9DD-B9DE, U+B9E1, U+B9E3-B9E5, U+B9E8, U+B9EC, + U+B9F4-B9F5, U+B9F7-B9FA, U+BA00-BA01, U+BA04, U+BA08, U+BA15, U+BA38-BA39, U+BA3C, U+BA40, U+BA42, + U+BA48-BA49, U+BA4B, U+BA4D-BA4E, U+BA53-BA55, U+BA58, U+BA5C, U+BA64-BA65, U+BA67-BA69, U+BA70-BA71, U+BA74, + U+BA78, U+BA83-BA85, U+BA87, U+BA8C, U+BAA8-BAA9, U+BAAB-BAAC, U+BAB0, U+BAB2, U+BAB8-BAB9, U+BABB, + U+BABD, U+BAC4, U+BAC8, U+BAD8-BAD9, U+BAFC, U+BB00, U+BB04, U+BB0D, U+BB0F, U+BB11, + U+BB18, U+BB1C, U+BB20, U+BB29, U+BB2B, U+BB34-BB36, U+BB38, U+BB3B-BB3E, U+BB44-BB45, U+BB47, + U+BB49, U+BB4D, U+BB4F-BB50, U+BB54, U+BB58, U+BB61, U+BB63, U+BB65, U+BB6C, U+BB88, + U+BB8C, U+BB90, U+BBA4, U+BBA8, U+BBAC, U+BBB4, U+BBB7, U+BBC0, U+BBC4, U+BBC8, + U+BBD0, U+BBD3, U+BBDC, U+BBF8-BBF9, U+BBFC, U+BBFF-BC00, U+BC02, U+BC08-BC09, U+BC0B-BC0D, U+BC0F, + U+BC11, U+BC14-BC18, U+BC1B-BC1F, U+BC24-BC25, U+BC27, U+BC29, U+BC2D, U+BC30-BC31, U+BC34, U+BC38, + U+BC40-BC41, U+BC43-BC45, U+BC49, U+BC4C-BC4D, U+BC50, U+BC5D, U+BC84-BC85, U+BC88, U+BC8B-BC8C, U+BC8E, + U+BC94-BC95, U+BC97, U+BC99-BC9A, U+BCA0-BCA1, U+BCA4, U+BCA7-BCA9, U+BCB0-BCB1, U+BCB3-BCB5, U+BCBC-BCBD, U+BCC0, + U+BCC4, U+BCCD, U+BCCF-BCD1, U+BCD5, U+BCD8, U+BCDC, U+BCF4-BCF6, U+BCF8, U+BCFC, U+BD04-BD05, + U+BD07, U+BD09, U+BD10, U+BD14, U+BD23-BD25, U+BD2C, U+BD35, U+BD40, U+BD48-BD49, U+BD4C, + U+BD50, U+BD58-BD59, U+BD64, U+BD68, U+BD79, U+BD80-BD81, U+BD84, U+BD87-BD8A, U+BD90-BD91, U+BD93, + U+BD95, U+BD99-BD9A, U+BD9C, U+BDA4, U+BDB0, U+BDB8-BDB9, U+BDC1, U+BDD4-BDD5, U+BDD8, U+BDDC, + U+BDE9, U+BDF0, U+BDF4, U+BDF8, U+BE00, U+BE03, U+BE05, U+BE0C-BE0D, U+BE10, U+BE14, + U+BE1C-BE1D, U+BE1F, U+BE44-BE45, U+BE48, U+BE4C, U+BE4E, U+BE54-BE55, U+BE57, U+BE59-BE5B, U+BE60-BE61, + U+BE64, U+BE68, U+BE6A, U+BE70-BE71, U+BE73-BE75, U+BE7B-BE7D, U+BE80, U+BE84, U+BE8C-BE8D, U+BE8F-BE91, + U+BE98-BE99, U+BEA8, U+BEAD, U+BEB4, U+BED0-BED1, U+BED4, U+BED7-BED8, U+BEE0, U+BEE3-BEE5, U+BEEC, + U+BEF5, U+BF01, U+BF08-BF09, U+BF18-BF19, U+BF1B-BF1D, U+BF40-BF41, U+BF44, U+BF48, U+BF50-BF51, U+BF55, + U+BF5C, U+BF71, U+BF94, U+BFB0, U+BFB8, U+BFC5, U+BFCC-BFCD, U+BFD0, U+BFD4, U+BFDC, + U+BFDF, U+BFE1, U+C00D, U+C03C, U+C04C, U+C051, U+C058, U+C05C, U+C060, U+C068-C069, + U+C090-C091, U+C094, U+C098, U+C0A0-C0A1, U+C0A3, U+C0A5, U+C0AC-C0AD, U+C0AF-C0B0, U+C0B3-C0B6, U+C0BC-C0BD, + U+C0BF-C0C1, U+C0C5, U+C0C8-C0C9, U+C0CC, U+C0D0, U+C0D8-C0D9, U+C0DB-C0DD, U+C0E4-C0E5, U+C0E8, U+C0EC, + U+C0F4-C0F5, U+C0F7, U+C0F9, U+C0FE, U+C100, U+C104, U+C108, U+C110, U+C115, U+C11C-C120, + U+C123-C124, U+C126-C127, U+C12C-C12D, U+C12F-C131, U+C136, U+C138-C139, U+C13C, U+C140, U+C142, U+C148-C149, + U+C14B-C14D, U+C154-C155, U+C158, U+C15C, U+C164-C165, U+C167-C169, U+C170, U+C174, U+C178, U+C185, + U+C18C-C18E, U+C190, U+C194, U+C196, U+C19C-C19D, U+C19F, U+C1A1, U+C1A5, U+C1A8-C1A9, U+C1AC, + U+C1B0, U+C1BD, U+C1C4, U+C1C8, U+C1CC, U+C1D4, U+C1D7-C1D8, U+C1E0, U+C1E4, U+C1E8, + U+C1F0-C1F1, U+C1F3, U+C1FC-C1FD, U+C200, U+C204, U+C20C-C20D, U+C20F, U+C211, U+C216, U+C218-C219, + U+C21C, U+C21F-C220, U+C228-C229, U+C22B, U+C22D, U+C22F, U+C231-C232, U+C234, U+C245, U+C248, + U+C250-C251, U+C254, U+C258, U+C25F-C260, U+C265, U+C26C-C26D, U+C270, U+C274, U+C27C-C27D, U+C27F, + U+C281, U+C288-C289, U+C28C, U+C290, U+C298-C299, U+C29B, U+C29D, U+C2A0, U+C2A4-C2A5, U+C2A8, + U+C2AC-C2AD, U+C2B4-C2B5, U+C2B7, U+C2B9, U+C2DC-C2DD, U+C2E0, U+C2E3-C2E4, U+C2EB-C2ED, U+C2EF, U+C2F1, + U+C2F6, U+C2F8-C2F9, U+C2FB-C2FC, U+C300, U+C308-C309, U+C30C-C30D, U+C313-C315, U+C318, U+C31C, U+C324-C325, + U+C328-C329, U+C330, U+C338, U+C345, U+C34C, U+C368-C369, U+C36C, U+C370, U+C372, U+C378-C379, + U+C37B-C37D, U+C384, U+C388, U+C38C, U+C395, U+C399, U+C3B5, U+C3BC, U+C3C0, U+C3D8-C3D9, + U+C3DC, U+C3DF-C3E0, U+C3E2, U+C3E8-C3E9, U+C3ED, U+C3F4-C3F5, U+C3F8, U+C3FC, U+C408, U+C410, + U+C424, U+C42C, U+C430, U+C434, U+C43C-C43D, U+C448, U+C450, U+C45D, U+C464-C465, U+C468, + U+C46C, U+C474-C475, U+C479, U+C480, U+C494, U+C49C-C49D, U+C4B8, U+C4BC, U+C4CD, U+C4D4, + U+C4D8, U+C4E9, U+C4F0-C4F1, U+C4F4, U+C4F8, U+C4FA, U+C4FF-C501, U+C50C, U+C510, U+C514, + U+C51C, U+C521, U+C528-C529, U+C52C, U+C530, U+C538-C539, U+C53B, U+C53D, U+C544-C545, U+C548-C54A, + U+C54C-C54E, U+C553-C555, U+C557-C559, U+C55C-C55E, U+C560-C561, U+C564, U+C568, U+C570-C571, U+C573-C575, U+C57B-C57D, + U+C580, U+C584, U+C587, U+C58C-C58D, U+C58F, U+C591, U+C595, U+C597-C598, U+C59C, U+C5A0, + U+C5A9, U+C5B4-C5B5, U+C5B8-C5B9, U+C5BB-C5BE, U+C5C4-C5CA, U+C5CC, U+C5CE, U+C5D0-C5D1, U+C5D4, U+C5D8, + U+C5E0-C5E1, U+C5E3, U+C5E5, U+C5EC-C5EE, U+C5F0, U+C5F4, U+C5F6-C5F7, U+C5FC-C601, U+C605-C608, U+C60C, + U+C610, U+C618-C619, U+C61B-C61C, U+C624-C625, U+C628, U+C62C-C62E, U+C630, U+C633-C635, U+C637, U+C639, + U+C63B, U+C640-C641, U+C644, U+C648, U+C650-C651, U+C653-C655, U+C65C-C65D, U+C660, U+C664, U+C66C, + U+C66F, U+C671, U+C674, U+C678-C679, U+C67C, U+C680, U+C688-C689, U+C68B, U+C68D, U+C694-C695, + U+C698, U+C69C, U+C6A4-C6A5, U+C6A7, U+C6A9, U+C6B0-C6B1, U+C6B4, U+C6B8-C6BA, U+C6C0-C6C1, U+C6C3, + U+C6C5, U+C6CC-C6CD, U+C6D0, U+C6D4, U+C6DC-C6DD, U+C6DF-C6E1, U+C6E8-C6E9, U+C6EC, U+C6F0-C6F1, U+C6F8-C6F9, + U+C6FD, U+C704-C705, U+C708, U+C70C, U+C714-C715, U+C717, U+C719, U+C720-C721, U+C724, U+C728, + U+C730-C731, U+C733, U+C735, U+C737, U+C73C-C73E, U+C740, U+C743-C744, U+C74A, U+C74C-C74F, U+C751-C758, + U+C75C, U+C760, U+C768, U+C76B, U+C774-C776, U+C778, U+C77C-C77E, U+C783-C785, U+C787-C78A, U+C78C, + U+C78E, U+C790-C791, U+C794, U+C796-C798, U+C79A, U+C7A0-C7A1, U+C7A3-C7A6, U+C7AC-C7AD, U+C7B0, U+C7B4, + U+C7BC-C7BD, U+C7BF-C7C1, U+C7C8-C7C9, U+C7CC, U+C7CE, U+C7D0, U+C7D8-C7D9, U+C7DD, U+C7E4, U+C7E8, + U+C7EC, U+C800-C801, U+C804, U+C808, U+C80A, U+C810-C811, U+C813, U+C815-C816, U+C81C-C81D, U+C820, + U+C824, U+C82C-C82D, U+C82F, U+C831, U+C838, U+C83C, U+C840, U+C848-C849, U+C84C-C84D, U+C854, + U+C870-C871, U+C874, U+C878, U+C87A, U+C880-C881, U+C883, U+C885-C887, U+C88B-C88D, U+C890, U+C894, + U+C89D, U+C89F, U+C8A1, U+C8A8, U+C8BC-C8BD, U+C8C4, U+C8C8, U+C8CC, U+C8D4-C8D5, U+C8D7, + U+C8D9, U+C8E0-C8E1, U+C8E4, U+C8E8, U+C8F5, U+C8FC-C8FD, U+C900, U+C904-C906, U+C90C-C90D, U+C90F, + U+C911, U+C918, U+C92C, U+C934-C935, U+C938, U+C950-C951, U+C954, U+C958, U+C960-C961, U+C963, + U+C965, U+C96C, U+C970, U+C974, U+C97C, U+C981, U+C988-C989, U+C98C, U+C990, U+C998-C999, + U+C99B, U+C99D, U+C9A4, U+C9A8, U+C9C0-C9C1, U+C9C4, U+C9C7-C9C8, U+C9CA, U+C9D0-C9D1, U+C9D3, + U+C9D5-C9D6, U+C9D9-C9DA, U+C9DC-C9DD, U+C9E0, U+C9E2, U+C9E4, U+C9E7, U+C9EC-C9ED, U+C9EF-C9F1, U+C9F8-C9F9, + U+C9FC, U+CA00, U+CA08-CA09, U+CA0B-CA0D, U+CA14, U+CA18, U+CA1C, U+CA29, U+CA30, U+CA4C-CA4D, + U+CA50, U+CA54, U+CA5C-CA5D, U+CA5F-CA61, U+CA68, U+CA7D, U+CA84, U+CA98, U+CABC-CABD, U+CAC0, + U+CAC4, U+CACC-CACD, U+CACF, U+CAD1, U+CAD3, U+CAD8-CAD9, U+CAE0, U+CAEC, U+CAF4, U+CB08, + U+CB10, U+CB14, U+CB18, U+CB20-CB21, U+CB2C, U+CB41, U+CB48-CB49, U+CB4C, U+CB50, U+CB58-CB59, + U+CB5D, U+CB64, U+CB78-CB79, U+CB9C, U+CBB8, U+CBD4, U+CBE4-CBE5, U+CBE7, U+CBE9, U+CC0C-CC0D, + U+CC10, U+CC14, U+CC1C-CC1D, U+CC21-CC22, U+CC27-CC29, U+CC2C, U+CC2E, U+CC30, U+CC38-CC39, U+CC3B-CC3E, + U+CC44-CC45, U+CC48, U+CC4C, U+CC54-CC55, U+CC57-CC59, U+CC60, U+CC64, U+CC66, U+CC68, U+CC70, + U+CC75, U+CC98-CC99, U+CC9C, U+CCA0, U+CCA8-CCA9, U+CCAB-CCAD, U+CCB4-CCB5, U+CCB8, U+CCBC, U+CCC4-CCC5, + U+CCC7, U+CCC9, U+CCD0, U+CCD4, U+CCE1, U+CCE4, U+CCEC, U+CCF0, U+CD01, U+CD08-CD09, + U+CD0C, U+CD10, U+CD18-CD19, U+CD1B, U+CD1D, U+CD24, U+CD28, U+CD2C, U+CD39, U+CD5C-CD5D, + U+CD60, U+CD64, U+CD6C-CD6D, U+CD6F, U+CD71, U+CD78, U+CD88, U+CD94-CD95, U+CD98, U+CD9C, + U+CDA4-CDA5, U+CDA7, U+CDA9, U+CDB0, U+CDC4, U+CDCC-CDCD, U+CDD0, U+CDDD, U+CDDF, U+CDE8, + U+CDEC, U+CDF0, U+CDF8-CDF9, U+CDFB, U+CDFD, U+CE04, U+CE08, U+CE0C, U+CE14-CE15, U+CE19, + U+CE20-CE21, U+CE24, U+CE28, U+CE30-CE31, U+CE33, U+CE35, U+CE58-CE59, U+CE5C, U+CE5F-CE61, U+CE68-CE69, + U+CE6B, U+CE6D, U+CE74-CE75, U+CE78, U+CE7C, U+CE84-CE85, U+CE87, U+CE89, U+CE8C, U+CE90-CE91, + U+CE94, U+CE98, U+CEA0-CEA1, U+CEA3-CEA5, U+CEAC-CEAE, U+CEB4, U+CEC1, U+CEC4, U+CEE4-CEE5, U+CEE8, + U+CEEB-CEEC, U+CEF4-CEF5, U+CEF7-CEF9, U+CF00-CF01, U+CF04, U+CF08, U+CF10-CF11, U+CF13, U+CF15, U+CF18, + U+CF1C, U+CF20, U+CF24, U+CF2C-CF2D, U+CF2F-CF31, U+CF38, U+CF54-CF55, U+CF58, U+CF5C, U+CF64-CF65, + U+CF67, U+CF69, U+CF70-CF71, U+CF74, U+CF78, U+CF80, U+CF85, U+CF8C, U+CFA1, U+CFA8, + U+CFB0, U+CFC4, U+CFDC, U+CFE0-CFE1, U+CFE4, U+CFE8, U+CFF0-CFF1, U+CFF3, U+CFF5, U+CFFC-CFFD, + U+D000, U+D004, U+D011, U+D018, U+D02D, U+D034-D035, U+D038, U+D03C, U+D044-D045, U+D047, + U+D049, U+D050, U+D054, U+D058, U+D060, U+D06C-D06E, U+D070, U+D074, U+D07C-D07D, U+D07F, + U+D081, U+D084, U+D0A4-D0A5, U+D0A8, U+D0AC, U+D0B4-D0B5, U+D0B7, U+D0B9, U+D0C0-D0C1, U+D0C4, + U+D0C8-D0C9, U+D0D0-D0D1, U+D0D3-D0D5, U+D0DC-D0DD, U+D0E0, U+D0E4, U+D0EC-D0ED, U+D0EF-D0F1, U+D0F8, U+D10D, + U+D130-D131, U+D134, U+D138, U+D13A, U+D140-D141, U+D143-D145, U+D14C-D14D, U+D150, U+D154, U+D15C-D15D, + U+D15F, U+D161, U+D168, U+D16C, U+D17C, U+D184, U+D188, U+D1A0-D1A1, U+D1A4, U+D1A8, + U+D1B0-D1B1, U+D1B3, U+D1B5, U+D1BA, U+D1BC, U+D1C0, U+D1D8, U+D1F4, U+D1F8, U+D207, + U+D209, U+D210, U+D22C-D22D, U+D230, U+D234, U+D23C-D23D, U+D23F, U+D241, U+D248, U+D25C, + U+D264, U+D277, U+D280-D281, U+D284, U+D288, U+D290-D291, U+D295, U+D29C, U+D2A0, U+D2A4, + U+D2AC, U+D2B1, U+D2B8-D2B9, U+D2BC, U+D2BF-D2C0, U+D2C2, U+D2C8-D2C9, U+D2CB, U+D2D4, U+D2D8, + U+D2DC, U+D2E4-D2E5, U+D2F0-D2F1, U+D2F4, U+D2F8, U+D300-D301, U+D303, U+D305, U+D30C-D30E, U+D310, + U+D314, U+D316, U+D31C-D31D, U+D31F-D321, U+D325, U+D328-D329, U+D32C, U+D330, U+D338-D339, U+D33B-D33D, + U+D344-D345, U+D37C-D37D, U+D380, U+D384, U+D38C-D38D, U+D38F-D391, U+D398-D399, U+D39C, U+D3A0, U+D3A8-D3A9, + U+D3AB, U+D3AD, U+D3B4, U+D3B8, U+D3BC, U+D3C4-D3C5, U+D3C8-D3C9, U+D3D0, U+D3D8, U+D3E1, + U+D3E3, U+D3EC-D3ED, U+D3F0, U+D3F4, U+D3FC-D3FD, U+D3FF, U+D401, U+D408, U+D41D, U+D440, + U+D444, U+D45C, U+D460, U+D464, U+D46D, U+D46F, U+D478-D479, U+D47C, U+D47F-D480, U+D482, + U+D488-D489, U+D48B, U+D48D, U+D494, U+D4A9, U+D4B0, U+D4CC, U+D4D0, U+D4D4, U+D4DC, + U+D4DF, U+D4E8, U+D4EC, U+D4F0, U+D4F8, U+D4FB, U+D4FD, U+D504, U+D508, U+D50C, + U+D514-D515, U+D517, U+D53C-D53D, U+D540, U+D544, U+D54C-D54D, U+D54F, U+D551, U+D558-D559, U+D55C, + U+D560, U+D565, U+D568-D569, U+D56B, U+D56D, U+D571, U+D573-D575, U+D578, U+D57C, U+D584-D585, + U+D587-D589, U+D58F-D590, U+D59D, U+D5A5, U+D5C8-D5C9, U+D5CC, U+D5D0, U+D5D2, U+D5D8-D5D9, U+D5DB, + U+D5DD, U+D5E4-D5E5, U+D5E8, U+D5EC, U+D5F3-D5F5, U+D5F7, U+D5F9, U+D5FF-D601, U+D604, U+D608, + U+D610-D611, U+D613-D615, U+D61C, U+D620, U+D624, U+D62D, U+D638-D639, U+D63C, U+D640, U+D645, + U+D648-D649, U+D64B, U+D64D, U+D651, U+D654-D655, U+D658, U+D65C, U+D664, U+D667, U+D669, + U+D670-D671, U+D674, U+D683, U+D685, U+D68C-D68D, U+D690, U+D694, U+D69D, U+D69F, U+D6A1, + U+D6A8, U+D6AC, U+D6B0, U+D6B9, U+D6BB, U+D6BD, U+D6C3-D6C5, U+D6C8, U+D6CC, U+D6D1, + U+D6D4-D6D5, U+D6D7, U+D6D9, U+D6E0, U+D6E4, U+D6E8, U+D6F0, U+D6F5, U+D6FC-D6FD, U+D700, + U+D704, U+D711, U+D718-D719, U+D71C, U+D720, U+D728-D729, U+D72B, U+D72D, U+D734-D735, U+D738, + U+D73C, U+D744, U+D747, U+D749, U+D750-D751, U+D754, U+D756-D759, U+D760-D761, U+D763, U+D765, + U+D769, U+D76C, U+D770, U+D774, U+D77C-D77D, U+D781, U+D788-D789, U+D78C, U+D790, U+D798-D799, + U+D79B, U+D79D, U+FF01-FF5E, U+FFE0-FFE3, U+FFE5-FFE6; +}