From 8b501266ffbd2a79ef0645e52e669714dea5b012 Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 19 Oct 2026 17:06:18 +0900 Subject: [PATCH] feat: improve agent responses and workspace UI Add live agent progress, elapsed timing, verified code navigation and rich Markdown rendering. Preserve NanumSquare styling with missing glyph fallback, fix platform availability filtering and unify workspace scrollbar and visualization controls. --- package-lock.json | 25 + package.json | 1 + scripts/generate-nanumsquare-css.py | 85 ++ scripts/test-agent-code-navigation.cjs | 172 ++++ scripts/test-agent-plan-cancel.cjs | 1 + scripts/test-agent-progress.cjs | 157 ++++ scripts/test-agent-response-reveal.cjs | 6 +- scripts/test-agent-work-duration.cjs | 87 ++ scripts/test-ai-response-presentation.cjs | 40 +- scripts/test-chat-clear-confirmation.cjs | 3 + scripts/test-chat-input-autosize.cjs | 1 + scripts/test-chat-markdown.cjs | 111 +++ scripts/test-chat-text-menu.cjs | 2 +- scripts/test-font-fallback.cjs | 61 ++ scripts/test-platform-availability.cjs | 86 ++ scripts/test-studio-theme.cjs | 15 + src/renderer/public/slam-viewer/index.html | 84 +- src/renderer/src/App.tsx | 51 +- .../src/components/AgentCodeReferences.tsx | 38 + .../src/components/AgentProgressBubble.tsx | 58 ++ .../src/components/AgentWorkDuration.tsx | 27 + src/renderer/src/components/ChatCodeBlock.tsx | 63 ++ src/renderer/src/components/ChatWidgets.tsx | 27 +- .../components/ProgressiveChatResponse.tsx | 6 +- .../src/features/workspace/ModuleListView.tsx | 2 +- .../features/workspace/WorkspaceQaPanel.tsx | 35 +- .../features/workspace/agentCodeNavigation.ts | 63 ++ .../src/features/workspace/agentContext.ts | 46 +- .../src/features/workspace/useAiAgent.ts | 77 +- src/renderer/src/i18n/en.json | 40 +- src/renderer/src/index.css | 35 +- src/renderer/src/styles/chatCode.css | 48 ++ src/renderer/src/styles/nanumSquare.css | 776 ++++++++++++++++++ 33 files changed, 2231 insertions(+), 98 deletions(-) create mode 100644 scripts/generate-nanumsquare-css.py create mode 100644 scripts/test-agent-code-navigation.cjs create mode 100644 scripts/test-agent-progress.cjs create mode 100644 scripts/test-agent-work-duration.cjs create mode 100644 scripts/test-chat-markdown.cjs create mode 100644 scripts/test-font-fallback.cjs create mode 100644 scripts/test-platform-availability.cjs create mode 100644 src/renderer/src/components/AgentCodeReferences.tsx create mode 100644 src/renderer/src/components/AgentProgressBubble.tsx create mode 100644 src/renderer/src/components/AgentWorkDuration.tsx create mode 100644 src/renderer/src/components/ChatCodeBlock.tsx create mode 100644 src/renderer/src/features/workspace/agentCodeNavigation.ts create mode 100644 src/renderer/src/styles/chatCode.css create mode 100644 src/renderer/src/styles/nanumSquare.css 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; +}