@things-factory/board-ai 10.0.0-beta.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/board-ai-chat.test.ts +120 -0
- package/client/components/board-ai-chat.ts +1502 -0
- package/client/components/chat-input-builder.ts +40 -0
- package/client/components/markdown.test.ts +220 -0
- package/client/components/markdown.ts +184 -0
- package/client/index.ts +11 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/board-edit-patch.ts +200 -0
- package/config/config.development.js +43 -0
- package/config/config.production.js +15 -0
- package/dist-client/components/board-ai-chat.d.ts +127 -0
- package/dist-client/components/board-ai-chat.js +1455 -0
- package/dist-client/components/board-ai-chat.js.map +1 -0
- package/dist-client/components/board-ai-chat.test.d.ts +1 -0
- package/dist-client/components/board-ai-chat.test.js +112 -0
- package/dist-client/components/board-ai-chat.test.js.map +1 -0
- package/dist-client/components/chat-input-builder.d.ts +30 -0
- package/dist-client/components/chat-input-builder.js +25 -0
- package/dist-client/components/chat-input-builder.js.map +1 -0
- package/dist-client/components/markdown.d.ts +16 -0
- package/dist-client/components/markdown.js +167 -0
- package/dist-client/components/markdown.js.map +1 -0
- package/dist-client/components/markdown.test.d.ts +1 -0
- package/dist-client/components/markdown.test.js +187 -0
- package/dist-client/components/markdown.test.js.map +1 -0
- package/dist-client/index.d.ts +11 -0
- package/dist-client/index.js +12 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/board-edit-patch.d.ts +73 -0
- package/dist-client/utils/board-edit-patch.js +159 -0
- package/dist-client/utils/board-edit-patch.js.map +1 -0
- package/dist-server/index.d.ts +21 -0
- package/dist-server/index.js +25 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/apply-patch.d.ts +46 -0
- package/dist-server/service/apply-patch.js +211 -0
- package/dist-server/service/apply-patch.js.map +1 -0
- package/dist-server/service/assistant.d.ts +75 -0
- package/dist-server/service/assistant.js +1298 -0
- package/dist-server/service/assistant.js.map +1 -0
- package/dist-server/service/board-ai-resolver.d.ts +40 -0
- package/dist-server/service/board-ai-resolver.js +260 -0
- package/dist-server/service/board-ai-resolver.js.map +1 -0
- package/dist-server/service/chat-message/chat-message.d.ts +24 -0
- package/dist-server/service/chat-message/chat-message.js +108 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -0
- package/dist-server/service/chat-message/index.d.ts +3 -0
- package/dist-server/service/chat-message/index.js +7 -0
- package/dist-server/service/chat-message/index.js.map +1 -0
- package/dist-server/service/chat-session/chat-session.d.ts +22 -0
- package/dist-server/service/chat-session/chat-session.js +109 -0
- package/dist-server/service/chat-session/chat-session.js.map +1 -0
- package/dist-server/service/chat-session/index.d.ts +3 -0
- package/dist-server/service/chat-session/index.js +7 -0
- package/dist-server/service/chat-session/index.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +13 -0
- package/dist-server/service/chat-session-resolver.js +178 -0
- package/dist-server/service/chat-session-resolver.js.map +1 -0
- package/dist-server/service/index.d.ts +14 -0
- package/dist-server/service/index.js +26 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/patch-entry/index.d.ts +3 -0
- package/dist-server/service/patch-entry/index.js +7 -0
- package/dist-server/service/patch-entry/index.js.map +1 -0
- package/dist-server/service/patch-entry/patch-entry.d.ts +16 -0
- package/dist-server/service/patch-entry/patch-entry.js +96 -0
- package/dist-server/service/patch-entry/patch-entry.js.map +1 -0
- package/dist-server/service/types.d.ts +137 -0
- package/dist-server/service/types.js +3 -0
- package/dist-server/service/types.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +47 -0
- package/server/index.ts +21 -0
- package/server/service/apply-patch.test.ts +640 -0
- package/server/service/apply-patch.ts +250 -0
- package/server/service/assistant.test.ts +1317 -0
- package/server/service/assistant.ts +1431 -0
- package/server/service/board-ai-resolver.ts +239 -0
- package/server/service/chat-message/chat-message.ts +110 -0
- package/server/service/chat-message/index.ts +5 -0
- package/server/service/chat-session/chat-session.ts +103 -0
- package/server/service/chat-session/index.ts +5 -0
- package/server/service/chat-session-resolver.ts +154 -0
- package/server/service/index.ts +24 -0
- package/server/service/patch-entry/index.ts +5 -0
- package/server/service/patch-entry/patch-entry.ts +89 -0
- package/server/service/types.ts +138 -0
- package/things-factory.config.js +1 -0
- package/translations/en.json +39 -0
- package/translations/ja.json +39 -0
- package/translations/ko.json +40 -0
- package/translations/ms.json +39 -0
- package/translations/zh.json +39 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* boardAIChat GraphQL mutation 의 input 객체 빌더.
|
|
3
|
+
*
|
|
4
|
+
* board-ai-chat.ts (Lit / Material Web 의존) 와 분리 — pure 함수만 두면 jest 의 node
|
|
5
|
+
* 환경에서도 import 가능. 회귀 테스트 (selectedRefids 누락 등) 가 단위 테스트로 잠긴다.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ChatMutationInputArgs {
|
|
9
|
+
sessionId?: string
|
|
10
|
+
history: Array<{ role: string; content: string }>
|
|
11
|
+
liveBoard: any
|
|
12
|
+
scopes?: string[]
|
|
13
|
+
knownTypes?: string[]
|
|
14
|
+
categories?: string[]
|
|
15
|
+
componentSchemas?: any
|
|
16
|
+
/**
|
|
17
|
+
* 선택된 컴포넌트의 `refid` 배열 — things-scene universal numeric handle.
|
|
18
|
+
* id (데이터 바인딩 이름) 와는 다른 개념.
|
|
19
|
+
*/
|
|
20
|
+
selectedRefids: number[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* GraphQL mutation 의 input 객체를 빌드.
|
|
25
|
+
*
|
|
26
|
+
* 핵심 보호 — `selectedRefids` 가 항상 포함되어야 한다. 누락이 사용자에게 보고된
|
|
27
|
+
* "AI 가 선택된 컴포넌트를 모른다" 의 root cause 였다.
|
|
28
|
+
*/
|
|
29
|
+
export function buildChatMutationInput(args: ChatMutationInputArgs): Record<string, any> {
|
|
30
|
+
return {
|
|
31
|
+
sessionId: args.sessionId ?? null,
|
|
32
|
+
messages: args.history,
|
|
33
|
+
currentBoard: args.liveBoard ?? null,
|
|
34
|
+
scopes: args.scopes,
|
|
35
|
+
knownTypes: args.knownTypes,
|
|
36
|
+
categories: args.categories,
|
|
37
|
+
componentSchemas: args.componentSchemas,
|
|
38
|
+
selectedRefids: args.selectedRefids
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderMarkdown 단위 테스트.
|
|
3
|
+
*
|
|
4
|
+
* 핵심 회귀 방지 영역:
|
|
5
|
+
* 1. 기본 markdown (bold, italic, list, code, link).
|
|
6
|
+
* 2. 보안 — raw HTML, javascript: 링크 차단.
|
|
7
|
+
* 3. CJK + 구두점 + ** 인접의 flanking 실패 케이스 (한국어 채팅 응답에서 빈번).
|
|
8
|
+
* 4. 코드 펜스/인라인 코드 안의 ** 는 건드리지 않음.
|
|
9
|
+
*/
|
|
10
|
+
import { renderMarkdown, escapeHtml, extractCjkStrongs } from './markdown'
|
|
11
|
+
|
|
12
|
+
describe('renderMarkdown — 기본 markdown', () => {
|
|
13
|
+
test('빈 문자열은 빈 문자열 반환', () => {
|
|
14
|
+
expect(renderMarkdown('')).toBe('')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('일반 텍스트는 <p> 로 감쌈', () => {
|
|
18
|
+
expect(renderMarkdown('hello')).toContain('<p>hello</p>')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('** 로 굵게', () => {
|
|
22
|
+
expect(renderMarkdown('**bold** text')).toContain('<strong>bold</strong>')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('* 로 기울임', () => {
|
|
26
|
+
expect(renderMarkdown('an *italic* word')).toContain('<em>italic</em>')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('- 로 unordered list', () => {
|
|
30
|
+
const out = renderMarkdown('- a\n- b\n- c')
|
|
31
|
+
expect(out).toContain('<ul>')
|
|
32
|
+
expect(out).toContain('<li>a</li>')
|
|
33
|
+
expect(out).toContain('<li>b</li>')
|
|
34
|
+
expect(out).toContain('<li>c</li>')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('inline code', () => {
|
|
38
|
+
expect(renderMarkdown('use `foo` here')).toContain('<code>foo</code>')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('fenced code block', () => {
|
|
42
|
+
const md = '```\nconst x = 1\n```'
|
|
43
|
+
expect(renderMarkdown(md)).toContain('<pre>')
|
|
44
|
+
expect(renderMarkdown(md)).toContain('<code>')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('헤더', () => {
|
|
48
|
+
expect(renderMarkdown('# Title')).toContain('<h1>Title</h1>')
|
|
49
|
+
expect(renderMarkdown('## Sub')).toContain('<h2>Sub</h2>')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('breaks: 단일 newline 은 <br>', () => {
|
|
53
|
+
expect(renderMarkdown('line1\nline2')).toContain('<br>')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('renderMarkdown — 보안', () => {
|
|
58
|
+
test('raw <script> 차단', () => {
|
|
59
|
+
const out = renderMarkdown("<script>alert('x')</script>")
|
|
60
|
+
expect(out).not.toContain('<script>')
|
|
61
|
+
expect(out).not.toContain('alert')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('raw <iframe> 차단', () => {
|
|
65
|
+
const out = renderMarkdown('<iframe src=x></iframe>')
|
|
66
|
+
expect(out).not.toContain('<iframe')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('inline HTML strong 차단 (renderer.html → 빈 문자열)', () => {
|
|
70
|
+
const out = renderMarkdown('Hello <strong>world</strong>')
|
|
71
|
+
// raw HTML 은 제거되고 텍스트만 남거나 비어야 함
|
|
72
|
+
expect(out).not.toContain('<strong>world</strong>')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('javascript: 링크 차단', () => {
|
|
76
|
+
const out = renderMarkdown("[click](javascript:alert(1))")
|
|
77
|
+
expect(out).not.toMatch(/href="javascript:/i)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('data: 링크 차단', () => {
|
|
81
|
+
const out = renderMarkdown('[x](data:text/html,<script>alert(1)</script>)')
|
|
82
|
+
expect(out).not.toMatch(/href="data:/i)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('정상 https 링크는 통과', () => {
|
|
86
|
+
const out = renderMarkdown('[ok](https://example.com)')
|
|
87
|
+
expect(out).toMatch(/href="https:\/\/example\.com"/)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('renderMarkdown — CJK + 구두점 인접 (회귀 방지)', () => {
|
|
92
|
+
// 이 그룹은 사용자 보고 사례. CommonMark flanking 규칙으로 인해 ** 가
|
|
93
|
+
// 닫히지 않던 케이스들을 모두 회복해야 한다.
|
|
94
|
+
const cases: Array<[string, string, string]> = [
|
|
95
|
+
["closing ** 이 punct + CJK 사이", "**'rect'**의", '<strong>'rect'</strong>의'],
|
|
96
|
+
["키 **'fillStyle'**의", "키 **'fillStyle'**의", '<strong>'fillStyle'</strong>의'],
|
|
97
|
+
["AGV 이름 + 조사", "**'AGV-1'**의 위치", '<strong>'AGV-1'</strong>의'],
|
|
98
|
+
["다중 인접 한글", "사용자가 **'AGV-1'**컴포넌트를", '<strong>'AGV-1'</strong>컴포넌트를'],
|
|
99
|
+
["bold 안 . 으로 끝", '**foo.**의', '<strong>foo.</strong>의'],
|
|
100
|
+
["bold 안 ) 으로 끝", '**foo)**의', '<strong>foo)</strong>의'],
|
|
101
|
+
["한 ** punct 시작 인접", "한**'foo'**", '한<strong>'foo'</strong>'],
|
|
102
|
+
["양쪽 모두 CJK + punct", "한**'foo'**글", '한<strong>'foo'</strong>글']
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
for (const [name, input, expected] of cases) {
|
|
106
|
+
test(name, () => {
|
|
107
|
+
const out = renderMarkdown(input)
|
|
108
|
+
expect(out).toContain(expected)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test('정상 케이스는 그대로 marked 가 처리', () => {
|
|
113
|
+
// 닫는 ** 가 공백 직전인 경우 — flanking 통과 (marked native; entity 표기는 ')
|
|
114
|
+
const a = renderMarkdown("키 **'fillStyle'** 의")
|
|
115
|
+
expect(a).toMatch(/<strong>(�?39;|')fillStyle(�?39;|')<\/strong>/)
|
|
116
|
+
const b = renderMarkdown("Type **'rect'** here")
|
|
117
|
+
expect(b).toMatch(/<strong>(�?39;|')rect(�?39;|')<\/strong>/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('사용자 실제 케이스 — 산업용 다크 모드 대시보드', () => {
|
|
121
|
+
const md = "**공장 설비 및 생산 현황을 한눈에 파악하기 위한 '산업용 다크 모드 대시보드'** 도 확인해보라."
|
|
122
|
+
const out = renderMarkdown(md)
|
|
123
|
+
expect(out).toContain('<strong>')
|
|
124
|
+
expect(out).toContain('대시보드')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('punct 가 끝/시작에 없으면 변경 없음', () => {
|
|
128
|
+
// marked 자체가 처리할 수 있는 케이스 — 우회 안 일어남
|
|
129
|
+
const out = renderMarkdown('한**foo**글')
|
|
130
|
+
expect(out).toContain('<strong>foo</strong>')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('일본어 인접', () => {
|
|
134
|
+
expect(renderMarkdown("これは**'値'**です")).toContain('<strong>'値'</strong>')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('중국어 인접', () => {
|
|
138
|
+
expect(renderMarkdown("这是**'值'**测试")).toContain('<strong>'值'</strong>')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('인라인 코드 안의 ** 는 건드리지 않음', () => {
|
|
142
|
+
// 코드 안의 ** 는 그대로 텍스트로 남아야 함
|
|
143
|
+
const out = renderMarkdown('`**not bold**`')
|
|
144
|
+
expect(out).toContain('<code>**not bold**</code>')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('코드 펜스 안의 ** 는 건드리지 않음', () => {
|
|
148
|
+
const md = '```\n**not bold**\n```'
|
|
149
|
+
const out = renderMarkdown(md)
|
|
150
|
+
expect(out).toContain('<code>')
|
|
151
|
+
// 펜스 안의 ** 는 marked 가 텍스트로 그대로 둠
|
|
152
|
+
expect(out).toMatch(/\*\*not bold\*\*/)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('placeholder 토큰 (BAICJKSTRONG…) 이 출력에 노출되지 않음', () => {
|
|
156
|
+
const out = renderMarkdown("키 **'fillStyle'**의")
|
|
157
|
+
expect(out).not.toContain('BAICJKSTRONG')
|
|
158
|
+
expect(out).not.toContain('ENDMARK')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('치환된 strong 내용은 HTML escape 됨', () => {
|
|
162
|
+
// CJK-fix 경로를 타는 케이스에 < > 가 섞여도 안전하게 escape 되어야 한다.
|
|
163
|
+
// before='', after='의' (CJK), innerStart=', innerEnd=' (둘 다 punct) → fix 트리거.
|
|
164
|
+
const out = renderMarkdown("**'<bad>'**의")
|
|
165
|
+
expect(out).not.toContain('<bad>')
|
|
166
|
+
expect(out).toContain('<bad>')
|
|
167
|
+
expect(out).toContain('<strong>')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('여러 strong 가 한 줄에 있어도 모두 처리', () => {
|
|
171
|
+
const out = renderMarkdown("**'A'**과 **'B'**는 다르다")
|
|
172
|
+
// 둘 다 strong 으로 렌더되어야 함
|
|
173
|
+
const matches = out.match(/<strong>/g) ?? []
|
|
174
|
+
expect(matches.length).toBe(2)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('extractCjkStrongs — 내부 동작', () => {
|
|
179
|
+
test('필요 없는 케이스는 stored 비어 있음', () => {
|
|
180
|
+
const r = extractCjkStrongs('regular **bold** text')
|
|
181
|
+
expect(r.stored).toEqual([])
|
|
182
|
+
expect(r.processed).toBe('regular **bold** text')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('필요한 케이스만 추출', () => {
|
|
186
|
+
const r = extractCjkStrongs("키 **'fillStyle'**의")
|
|
187
|
+
expect(r.stored).toEqual(["'fillStyle'"])
|
|
188
|
+
expect(r.processed).toContain('BAICJKSTRONG')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('인라인 코드 안의 ** 는 추출 안 함', () => {
|
|
192
|
+
const r = extractCjkStrongs("`**'foo'**의` 와 텍스트")
|
|
193
|
+
expect(r.stored).toEqual([])
|
|
194
|
+
expect(r.processed).toContain("`**'foo'**의`")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('코드 펜스 안의 ** 는 추출 안 함', () => {
|
|
198
|
+
const r = extractCjkStrongs("```\n**'foo'**의\n```")
|
|
199
|
+
expect(r.stored).toEqual([])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('짝이 안 맞는 ** 는 무시', () => {
|
|
203
|
+
const r = extractCjkStrongs('한**foo and 글')
|
|
204
|
+
expect(r.stored).toEqual([])
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('escapeHtml — HTML 이스케이프', () => {
|
|
209
|
+
test('< > & " \' 모두 escape', () => {
|
|
210
|
+
expect(escapeHtml('<script>')).toBe('<script>')
|
|
211
|
+
expect(escapeHtml('a & b')).toBe('a & b')
|
|
212
|
+
expect(escapeHtml('"q"')).toBe('"q"')
|
|
213
|
+
expect(escapeHtml("'a'")).toBe(''a'')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('일반 문자는 변경 없음', () => {
|
|
217
|
+
expect(escapeHtml('hello world')).toBe('hello world')
|
|
218
|
+
expect(escapeHtml('한글 テスト 中文')).toBe('한글 テスト 中文')
|
|
219
|
+
})
|
|
220
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board AI 채팅 메시지의 Markdown 렌더링.
|
|
3
|
+
*
|
|
4
|
+
* 보안:
|
|
5
|
+
* - raw HTML 차단 (renderer.html → ''), 위험 link 스킴 (javascript:, data: 등) 차단.
|
|
6
|
+
*
|
|
7
|
+
* CJK 보강:
|
|
8
|
+
* - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와
|
|
9
|
+
* ** 강조 마커 사이에 구두점이 끼면 마커가 닫히지 않는 케이스가 발생한다.
|
|
10
|
+
* 예) "**'rect'**의" → 닫는 ** 가 [구두점] + [CJK] 사이에 위치하므로 right-flanking 실패
|
|
11
|
+
* - 이런 케이스는 marked 에 넘기기 전에 자체적으로 추출 → 자리표시자로 치환 → marked
|
|
12
|
+
* 수행 → 후처리에서 <strong>...</strong> 로 복원하는 방식으로 처리한다.
|
|
13
|
+
* - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은
|
|
14
|
+
* escapeHtml 로 항상 escape).
|
|
15
|
+
*/
|
|
16
|
+
import { marked } from 'marked'
|
|
17
|
+
|
|
18
|
+
const markdownRenderer = new marked.Renderer()
|
|
19
|
+
markdownRenderer.html = () => ''
|
|
20
|
+
const origLink = markdownRenderer.link.bind(markdownRenderer)
|
|
21
|
+
markdownRenderer.link = (token: any) => {
|
|
22
|
+
const href = typeof token?.href === 'string' ? token.href.trim() : ''
|
|
23
|
+
if (/^(javascript|data|vbscript):/i.test(href)) {
|
|
24
|
+
return token?.text ?? ''
|
|
25
|
+
}
|
|
26
|
+
return origLink(token)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
marked.setOptions({
|
|
30
|
+
gfm: true,
|
|
31
|
+
breaks: true,
|
|
32
|
+
pedantic: false,
|
|
33
|
+
renderer: markdownRenderer,
|
|
34
|
+
async: false
|
|
35
|
+
} as any)
|
|
36
|
+
|
|
37
|
+
export function escapeHtml(text: string): string {
|
|
38
|
+
return text
|
|
39
|
+
.replace(/&/g, '&')
|
|
40
|
+
.replace(/</g, '<')
|
|
41
|
+
.replace(/>/g, '>')
|
|
42
|
+
.replace(/"/g, '"')
|
|
43
|
+
.replace(/'/g, ''')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// CJK 문자 범위: Hiragana, Katakana, CJK Unified Ideographs (Ext A 포함), Hangul, 전각/반각
|
|
47
|
+
const CJK_RE = /[-ヿ㐀-䶿一-鿿가--]/
|
|
48
|
+
const PUNCT_RE = /\p{P}/u
|
|
49
|
+
|
|
50
|
+
// marked 가 절대 변형하지 않는 토큰 (영숫자만 사용)
|
|
51
|
+
const PLACEHOLDER_PREFIX = 'BAICJKSTRONG'
|
|
52
|
+
const PLACEHOLDER_SUFFIX = 'ENDMARK'
|
|
53
|
+
|
|
54
|
+
interface ExtractResult {
|
|
55
|
+
processed: string
|
|
56
|
+
stored: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `**X**` 패턴 중 CJK + 구두점 인접 때문에 marked 의 flanking 규칙에서 누락되는
|
|
61
|
+
* 케이스를 추출. 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않는다.
|
|
62
|
+
*
|
|
63
|
+
* 검출 기준 (CommonMark right-flanking 실패 ↔ left-flanking 실패):
|
|
64
|
+
* - 닫는 ** 직전 문자가 구두점 AND 직후 문자가 CJK letter
|
|
65
|
+
* - 여는 ** 직전 문자가 CJK letter AND 직후 문자가 구두점
|
|
66
|
+
*/
|
|
67
|
+
export function extractCjkStrongs(text: string): ExtractResult {
|
|
68
|
+
const stored: string[] = []
|
|
69
|
+
const lines = text.split('\n')
|
|
70
|
+
const outLines: string[] = []
|
|
71
|
+
let inFence = false
|
|
72
|
+
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
if (/^\s{0,3}```/.test(line)) {
|
|
75
|
+
inFence = !inFence
|
|
76
|
+
outLines.push(line)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (inFence) {
|
|
80
|
+
outLines.push(line)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
outLines.push(processLine(line, stored, () => outLines[outLines.length - 1] ?? ''))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { processed: outLines.join('\n'), stored }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function processLine(line: string, stored: string[], prevLine: () => string): string {
|
|
90
|
+
let i = 0
|
|
91
|
+
let result = ''
|
|
92
|
+
while (i < line.length) {
|
|
93
|
+
const ch = line[i]
|
|
94
|
+
|
|
95
|
+
if (ch === '`') {
|
|
96
|
+
const end = line.indexOf('`', i + 1)
|
|
97
|
+
if (end === -1) {
|
|
98
|
+
result += line.slice(i)
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
result += line.slice(i, end + 1)
|
|
102
|
+
i = end + 1
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (ch === '*' && line[i + 1] === '*') {
|
|
107
|
+
const close = findCloseStrong(line, i + 2)
|
|
108
|
+
if (close === -1) {
|
|
109
|
+
result += '**'
|
|
110
|
+
i += 2
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const inner = line.slice(i + 2, close)
|
|
114
|
+
if (inner.length === 0) {
|
|
115
|
+
result += '**'
|
|
116
|
+
i += 2
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const before = result.length > 0 ? result.slice(-1) : prevLine().slice(-1)
|
|
120
|
+
const after = close + 2 < line.length ? line[close + 2] : ''
|
|
121
|
+
if (needsCjkFix(before, after, inner)) {
|
|
122
|
+
stored.push(inner)
|
|
123
|
+
result += `${PLACEHOLDER_PREFIX}${stored.length - 1}${PLACEHOLDER_SUFFIX}`
|
|
124
|
+
} else {
|
|
125
|
+
result += line.slice(i, close + 2)
|
|
126
|
+
}
|
|
127
|
+
i = close + 2
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
result += ch
|
|
132
|
+
i++
|
|
133
|
+
}
|
|
134
|
+
return result
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function findCloseStrong(line: string, start: number): number {
|
|
138
|
+
let i = start
|
|
139
|
+
while (i < line.length - 1) {
|
|
140
|
+
if (line[i] === '`') {
|
|
141
|
+
const end = line.indexOf('`', i + 1)
|
|
142
|
+
if (end === -1) return -1
|
|
143
|
+
i = end + 1
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if (line[i] === '*' && line[i + 1] === '*') return i
|
|
147
|
+
i++
|
|
148
|
+
}
|
|
149
|
+
return -1
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function needsCjkFix(before: string, after: string, inner: string): boolean {
|
|
153
|
+
const innerStart = inner[0]
|
|
154
|
+
const innerEnd = inner[inner.length - 1]
|
|
155
|
+
return (
|
|
156
|
+
(before !== '' && CJK_RE.test(before) && PUNCT_RE.test(innerStart)) ||
|
|
157
|
+
(after !== '' && CJK_RE.test(after) && PUNCT_RE.test(innerEnd))
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function restoreCjkStrongs(html: string, stored: string[]): string {
|
|
162
|
+
let out = html
|
|
163
|
+
for (let n = 0; n < stored.length; n++) {
|
|
164
|
+
const token = `${PLACEHOLDER_PREFIX}${n}${PLACEHOLDER_SUFFIX}`
|
|
165
|
+
const replacement = `<strong>${escapeHtml(stored[n])}</strong>`
|
|
166
|
+
out = out.split(token).join(replacement)
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function renderMarkdown(text: string): string {
|
|
172
|
+
if (!text) return ''
|
|
173
|
+
try {
|
|
174
|
+
const { processed, stored } = extractCjkStrongs(text)
|
|
175
|
+
const out = marked.parse(processed)
|
|
176
|
+
if (typeof out !== 'string') {
|
|
177
|
+
// marked 가 Promise 를 반환했다면 (async lexer 등) 안전한 fallback
|
|
178
|
+
return escapeHtml(text).replace(/\n/g, '<br>')
|
|
179
|
+
}
|
|
180
|
+
return restoreCjkStrongs(out, stored)
|
|
181
|
+
} catch {
|
|
182
|
+
return escapeHtml(text).replace(/\n/g, '<br>')
|
|
183
|
+
}
|
|
184
|
+
}
|
package/client/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @things-factory/board-ai — client entries.
|
|
3
|
+
*
|
|
4
|
+
* - `<ox-board-ai-chat>`: 자연어 채팅 컴포넌트 (Lit). board-modeller 또는 워크스페이스에서 임베드.
|
|
5
|
+
*
|
|
6
|
+
* 향후 추가 예정:
|
|
7
|
+
* - `<ox-board-workspace>`: editor / ai / split 모드 전환 컨테이너
|
|
8
|
+
* - 모드 전환 애니메이션 + 단축키 + 자동 전환 룰
|
|
9
|
+
*/
|
|
10
|
+
export * from './components/board-ai-chat.js'
|
|
11
|
+
export * from './utils/board-edit-patch.js'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig-base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"experimentalDecorators": true,
|
|
5
|
+
"skipLibCheck": true,
|
|
6
|
+
"strict": true,
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"module": "esnext",
|
|
9
|
+
"outDir": "../dist-client",
|
|
10
|
+
"baseUrl": "./"
|
|
11
|
+
},
|
|
12
|
+
"include": ["./**/*"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side BoardEditPatch types + applier.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: 동일 정의가 server/service/types.ts + apply-patch.ts 에 있음.
|
|
5
|
+
* client 번들이 server 코드를 import 하지 않도록 분리. 변경 시 양쪽 동기화 필요.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 컴포넌트 타깃팅은 항상 `refid` (things-scene universal numeric handle).
|
|
10
|
+
* `id` 는 데이터 바인딩 이름이며 unique 가 아니므로 targeting 에 사용하지 않는다.
|
|
11
|
+
*
|
|
12
|
+
* 보드는 최상위 부모 — 자체 속성 (fillStyle / width / height / name 등) 을 갖고,
|
|
13
|
+
* `modifyBoard` 로 변경. 자식 컴포넌트 변경 (`modify`) 와 분리.
|
|
14
|
+
*/
|
|
15
|
+
export type BoardEditOp =
|
|
16
|
+
| { op: 'add'; component: any }
|
|
17
|
+
| { op: 'remove'; refid: number }
|
|
18
|
+
| { op: 'modify'; refid: number; patch: any }
|
|
19
|
+
| { op: 'modifyBoard'; patch: any }
|
|
20
|
+
| { op: 'replace'; board: any }
|
|
21
|
+
|
|
22
|
+
export interface BoardEditPatch {
|
|
23
|
+
ops: BoardEditOp[]
|
|
24
|
+
summary: string
|
|
25
|
+
confidence: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PatchApplyReport {
|
|
29
|
+
/** 패치 적용 후 보드. 모든 op 가 noop 이어도 입력 그대로 반환. */
|
|
30
|
+
board: any
|
|
31
|
+
/** 실제로 보드를 바꾼 op 들. */
|
|
32
|
+
applied: BoardEditOp[]
|
|
33
|
+
/** id 매칭 실패 등으로 noop 이 된 op 들 — 호출자가 사용자에게 알릴 단서. */
|
|
34
|
+
missed: BoardEditOp[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const EMPTY_BOARD = { width: 1000, height: 600, components: [] as any[] }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Patch 를 BoardModel 에 적용 (pure, board 를 mutate 하지 않음).
|
|
41
|
+
*/
|
|
42
|
+
export function applyBoardEditPatch(board: any | undefined, patch: BoardEditPatch): any {
|
|
43
|
+
return applyBoardEditPatchVerbose(board, patch).board
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Verbose 변형 — 각 op 의 적용 여부를 보고.
|
|
48
|
+
*
|
|
49
|
+
* `modify` 와 `remove` 는 id 가 보드에 없으면 silent no-op 이 된다. LLM 이 잘못된
|
|
50
|
+
* id 를 만들어 보내면 사용자에게 "수정했습니다" 라고 답하지만 실제로는 아무 변화도
|
|
51
|
+
* 없는 상황이 발생 — 호스트가 missed 를 보고 사용자에게 경고할 수 있도록 별도
|
|
52
|
+
* entry point 제공.
|
|
53
|
+
*/
|
|
54
|
+
export function applyBoardEditPatchVerbose(
|
|
55
|
+
board: any | undefined,
|
|
56
|
+
patch: BoardEditPatch
|
|
57
|
+
): PatchApplyReport {
|
|
58
|
+
let result: any = board ?? EMPTY_BOARD
|
|
59
|
+
const applied: BoardEditOp[] = []
|
|
60
|
+
const missed: BoardEditOp[] = []
|
|
61
|
+
|
|
62
|
+
for (const op of patch.ops) {
|
|
63
|
+
const next = applyOp(result, op)
|
|
64
|
+
if (next === result || componentsUnchanged(result, next)) {
|
|
65
|
+
missed.push(op)
|
|
66
|
+
} else {
|
|
67
|
+
applied.push(op)
|
|
68
|
+
result = next
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { board: result, applied, missed }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function componentsUnchanged(prev: any, next: any): boolean {
|
|
76
|
+
// applyOp 는 항상 새 객체를 만든다. 따라서 reference 비교가 안 되고 내용 비교 필요.
|
|
77
|
+
// components 는 map/filter 결과 reference 도 다를 수 있으므로 length + 요소 ref 비교.
|
|
78
|
+
const a = prev.components ?? []
|
|
79
|
+
const b = next.components ?? []
|
|
80
|
+
if (a.length !== b.length) return false
|
|
81
|
+
for (let i = 0; i < a.length; i++) {
|
|
82
|
+
if (a[i] !== b[i]) return false
|
|
83
|
+
}
|
|
84
|
+
return prev.width === next.width && prev.height === next.height && prev.fillStyle === next.fillStyle
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function applyOp(board: any, op: BoardEditOp): any {
|
|
88
|
+
switch (op.op) {
|
|
89
|
+
case 'replace':
|
|
90
|
+
return op.board
|
|
91
|
+
case 'add':
|
|
92
|
+
return { ...board, components: [...(board.components || []), op.component] }
|
|
93
|
+
case 'remove':
|
|
94
|
+
return {
|
|
95
|
+
...board,
|
|
96
|
+
components: (board.components || []).filter((c: any) => c?.refid !== op.refid)
|
|
97
|
+
}
|
|
98
|
+
case 'modify':
|
|
99
|
+
return {
|
|
100
|
+
...board,
|
|
101
|
+
components: (board.components || []).map((c: any) =>
|
|
102
|
+
c?.refid === op.refid ? mergeComponent(c, op.patch) : c
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
case 'modifyBoard': {
|
|
106
|
+
const patch = { ...(op.patch || {}) }
|
|
107
|
+
delete patch.components // 자식 변경은 별도 op
|
|
108
|
+
return mergeComponent(board, patch) // 최상위 board 자체에 deep-merge
|
|
109
|
+
}
|
|
110
|
+
default:
|
|
111
|
+
return board
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 주어진 board 상태에서 op 의 inverse 를 계산.
|
|
117
|
+
*
|
|
118
|
+
* Revert 기능 — patch 적용 직전 board + op 로 역연산을 만든다. 호스트가
|
|
119
|
+
* 누적해두면 나중에 역순 실행만으로 복원.
|
|
120
|
+
*
|
|
121
|
+
* add 의 inverse 는 새로 발급될 refid 를 알아야 → 모델 단계에서 계산 불가.
|
|
122
|
+
* 호스트가 scene.add 직후 refid 를 캡처해 직접 만들 것.
|
|
123
|
+
*/
|
|
124
|
+
export function computeInverseOp(board: any, op: BoardEditOp): BoardEditOp | null {
|
|
125
|
+
if (!board) return null
|
|
126
|
+
const components = board.components ?? []
|
|
127
|
+
|
|
128
|
+
switch (op.op) {
|
|
129
|
+
case 'add':
|
|
130
|
+
return null
|
|
131
|
+
|
|
132
|
+
case 'remove': {
|
|
133
|
+
const target = components.find((c: any) => c?.refid === op.refid)
|
|
134
|
+
if (!target) return null
|
|
135
|
+
return { op: 'add', component: JSON.parse(JSON.stringify(target)) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'modify': {
|
|
139
|
+
const target = components.find((c: any) => c?.refid === op.refid)
|
|
140
|
+
if (!target) return null
|
|
141
|
+
const oldValues: any = {}
|
|
142
|
+
for (const k of Object.keys(op.patch || {})) {
|
|
143
|
+
const v = (target as any)[k]
|
|
144
|
+
oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v))
|
|
145
|
+
}
|
|
146
|
+
return { op: 'modify', refid: op.refid, patch: oldValues }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case 'modifyBoard': {
|
|
150
|
+
const oldValues: any = {}
|
|
151
|
+
const patch = op.patch || {}
|
|
152
|
+
for (const k of Object.keys(patch)) {
|
|
153
|
+
if (k === 'components') continue
|
|
154
|
+
const v = (board as any)[k]
|
|
155
|
+
oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v))
|
|
156
|
+
}
|
|
157
|
+
return { op: 'modifyBoard', patch: oldValues }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'replace':
|
|
161
|
+
return { op: 'replace', board: JSON.parse(JSON.stringify(board)) }
|
|
162
|
+
|
|
163
|
+
default:
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 컴포넌트에 부분 patch 를 적용 (deep merge).
|
|
170
|
+
* threeD 등 nested object 는 deep merge — 색만 바꾸려고 했을 때 geometry 까지 사라지지 않도록.
|
|
171
|
+
*
|
|
172
|
+
* host (board-modeller-page) 에서 things-scene 의 component.set(merged) 호출 전에 사용.
|
|
173
|
+
*/
|
|
174
|
+
export function mergeComponent(base: any, patch: any): any {
|
|
175
|
+
const out: any = { ...base }
|
|
176
|
+
for (const key of Object.keys(patch)) {
|
|
177
|
+
if (isPlainObject(base[key]) && isPlainObject(patch[key])) {
|
|
178
|
+
out[key] = deepMerge(base[key], patch[key])
|
|
179
|
+
} else {
|
|
180
|
+
out[key] = patch[key]
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function deepMerge(a: any, b: any): any {
|
|
187
|
+
const out: any = { ...a }
|
|
188
|
+
for (const key of Object.keys(b)) {
|
|
189
|
+
if (isPlainObject(a[key]) && isPlainObject(b[key])) {
|
|
190
|
+
out[key] = deepMerge(a[key], b[key])
|
|
191
|
+
} else {
|
|
192
|
+
out[key] = b[key]
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isPlainObject(v: any): boolean {
|
|
199
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
200
|
+
}
|