@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.
Files changed (95) hide show
  1. package/client/components/board-ai-chat.test.ts +120 -0
  2. package/client/components/board-ai-chat.ts +1502 -0
  3. package/client/components/chat-input-builder.ts +40 -0
  4. package/client/components/markdown.test.ts +220 -0
  5. package/client/components/markdown.ts +184 -0
  6. package/client/index.ts +11 -0
  7. package/client/tsconfig.json +13 -0
  8. package/client/utils/board-edit-patch.ts +200 -0
  9. package/config/config.development.js +43 -0
  10. package/config/config.production.js +15 -0
  11. package/dist-client/components/board-ai-chat.d.ts +127 -0
  12. package/dist-client/components/board-ai-chat.js +1455 -0
  13. package/dist-client/components/board-ai-chat.js.map +1 -0
  14. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  15. package/dist-client/components/board-ai-chat.test.js +112 -0
  16. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  17. package/dist-client/components/chat-input-builder.d.ts +30 -0
  18. package/dist-client/components/chat-input-builder.js +25 -0
  19. package/dist-client/components/chat-input-builder.js.map +1 -0
  20. package/dist-client/components/markdown.d.ts +16 -0
  21. package/dist-client/components/markdown.js +167 -0
  22. package/dist-client/components/markdown.js.map +1 -0
  23. package/dist-client/components/markdown.test.d.ts +1 -0
  24. package/dist-client/components/markdown.test.js +187 -0
  25. package/dist-client/components/markdown.test.js.map +1 -0
  26. package/dist-client/index.d.ts +11 -0
  27. package/dist-client/index.js +12 -0
  28. package/dist-client/index.js.map +1 -0
  29. package/dist-client/tsconfig.tsbuildinfo +1 -0
  30. package/dist-client/utils/board-edit-patch.d.ts +73 -0
  31. package/dist-client/utils/board-edit-patch.js +159 -0
  32. package/dist-client/utils/board-edit-patch.js.map +1 -0
  33. package/dist-server/index.d.ts +21 -0
  34. package/dist-server/index.js +25 -0
  35. package/dist-server/index.js.map +1 -0
  36. package/dist-server/service/apply-patch.d.ts +46 -0
  37. package/dist-server/service/apply-patch.js +211 -0
  38. package/dist-server/service/apply-patch.js.map +1 -0
  39. package/dist-server/service/assistant.d.ts +75 -0
  40. package/dist-server/service/assistant.js +1298 -0
  41. package/dist-server/service/assistant.js.map +1 -0
  42. package/dist-server/service/board-ai-resolver.d.ts +40 -0
  43. package/dist-server/service/board-ai-resolver.js +260 -0
  44. package/dist-server/service/board-ai-resolver.js.map +1 -0
  45. package/dist-server/service/chat-message/chat-message.d.ts +24 -0
  46. package/dist-server/service/chat-message/chat-message.js +108 -0
  47. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  48. package/dist-server/service/chat-message/index.d.ts +3 -0
  49. package/dist-server/service/chat-message/index.js +7 -0
  50. package/dist-server/service/chat-message/index.js.map +1 -0
  51. package/dist-server/service/chat-session/chat-session.d.ts +22 -0
  52. package/dist-server/service/chat-session/chat-session.js +109 -0
  53. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  54. package/dist-server/service/chat-session/index.d.ts +3 -0
  55. package/dist-server/service/chat-session/index.js +7 -0
  56. package/dist-server/service/chat-session/index.js.map +1 -0
  57. package/dist-server/service/chat-session-resolver.d.ts +13 -0
  58. package/dist-server/service/chat-session-resolver.js +178 -0
  59. package/dist-server/service/chat-session-resolver.js.map +1 -0
  60. package/dist-server/service/index.d.ts +14 -0
  61. package/dist-server/service/index.js +26 -0
  62. package/dist-server/service/index.js.map +1 -0
  63. package/dist-server/service/patch-entry/index.d.ts +3 -0
  64. package/dist-server/service/patch-entry/index.js +7 -0
  65. package/dist-server/service/patch-entry/index.js.map +1 -0
  66. package/dist-server/service/patch-entry/patch-entry.d.ts +16 -0
  67. package/dist-server/service/patch-entry/patch-entry.js +96 -0
  68. package/dist-server/service/patch-entry/patch-entry.js.map +1 -0
  69. package/dist-server/service/types.d.ts +137 -0
  70. package/dist-server/service/types.js +3 -0
  71. package/dist-server/service/types.js.map +1 -0
  72. package/dist-server/tsconfig.tsbuildinfo +1 -0
  73. package/package.json +47 -0
  74. package/server/index.ts +21 -0
  75. package/server/service/apply-patch.test.ts +640 -0
  76. package/server/service/apply-patch.ts +250 -0
  77. package/server/service/assistant.test.ts +1317 -0
  78. package/server/service/assistant.ts +1431 -0
  79. package/server/service/board-ai-resolver.ts +239 -0
  80. package/server/service/chat-message/chat-message.ts +110 -0
  81. package/server/service/chat-message/index.ts +5 -0
  82. package/server/service/chat-session/chat-session.ts +103 -0
  83. package/server/service/chat-session/index.ts +5 -0
  84. package/server/service/chat-session-resolver.ts +154 -0
  85. package/server/service/index.ts +24 -0
  86. package/server/service/patch-entry/index.ts +5 -0
  87. package/server/service/patch-entry/patch-entry.ts +89 -0
  88. package/server/service/types.ts +138 -0
  89. package/things-factory.config.js +1 -0
  90. package/translations/en.json +39 -0
  91. package/translations/ja.json +39 -0
  92. package/translations/ko.json +40 -0
  93. package/translations/ms.json +39 -0
  94. package/translations/zh.json +39 -0
  95. package/tsconfig.json +9 -0
@@ -0,0 +1,1502 @@
1
+ /**
2
+ * <ox-board-ai-chat> — AI 주도 보드 모델링 채팅 컴포넌트 (Lit).
3
+ *
4
+ * 입력:
5
+ * - sessionId: 영속 ChatSession 식별자 (없으면 ad-hoc 모드, 메시지 영속 안 됨)
6
+ * - currentBoard: 현재 BoardModel JSON (호스트가 동기화)
7
+ * - scopes / knownTypes / categories: 도메인 컨텍스트
8
+ *
9
+ * 출력 (이벤트):
10
+ * - `board-edit-patch` { detail: { patch, summary, confidence, patchId } }
11
+ * 호스트가 받아서 보드 모델에 적용 (applyBoardEditPatch helper).
12
+ * - `chat-followup` { detail: { question } }
13
+ *
14
+ * 모드 전환은 컨테이너 (워크스페이스) 의 책임. 이 컴포넌트는 자체로 풀 채팅 UX.
15
+ */
16
+ import '@material/web/icon/icon.js'
17
+
18
+ import { LitElement, css, html, nothing } from 'lit'
19
+ import { customElement, property, state } from 'lit/decorators.js'
20
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js'
21
+ import { client } from '@operato/graphql'
22
+ import { i18next, localize } from '@operato/i18n'
23
+ import { ScrollbarStyles } from '@operato/styles'
24
+ import gql from 'graphql-tag'
25
+
26
+ import { renderMarkdown } from './markdown.js'
27
+ import { buildChatMutationInput } from './chat-input-builder.js'
28
+
29
+ interface ChatLine {
30
+ role: 'user' | 'assistant' | 'system'
31
+ content: string
32
+ patchSummary?: string
33
+ followUp?: string
34
+ patchId?: string
35
+ pending?: boolean
36
+ /** AI 가 응답을 만드는 동안 호출한 도구 trace — fold-able 박스에서 노출. */
37
+ toolUsages?: Array<{ name: string; arguments: any; result: any; kind: 'read' | 'write' | 'unknown' }>
38
+ /** UI 상태: tool usage 박스 펼침 여부 (assistant 메시지마다 독립). */
39
+ toolUsagesOpen?: boolean
40
+ }
41
+
42
+ const BOARD_AI_CHAT_MUTATION = gql`
43
+ mutation BoardAIChat($input: BoardAIChatInput!) {
44
+ boardAIChat(input: $input) {
45
+ reply
46
+ patch
47
+ followUp
48
+ clientId
49
+ sessionId
50
+ userMessageId
51
+ assistantMessageId
52
+ patchId
53
+ toolUsages
54
+ }
55
+ }
56
+ `
57
+
58
+ const CHAT_MESSAGES_QUERY = gql`
59
+ query ChatMessages($sessionId: String!, $limit: Int, $offset: Int) {
60
+ chatMessages(sessionId: $sessionId, limit: $limit, offset: $offset) {
61
+ id
62
+ role
63
+ content
64
+ relatedPatchId
65
+ toolUsagesJson
66
+ createdAt
67
+ }
68
+ }
69
+ `
70
+
71
+ @customElement('ox-board-ai-chat')
72
+ export class OxBoardAIChat extends LitElement {
73
+ /** 영속 세션 id. 없으면 ad-hoc (메시지 영속 안 됨). */
74
+ @property({ type: String, attribute: 'session-id' })
75
+ sessionId?: string
76
+
77
+ /**
78
+ * 현재 BoardModel — chat() 입력으로 그대로 전달.
79
+ *
80
+ * 주의: 호스트가 라이브 편집 가능한 캔버스(things-scene)를 갖고 있다면, 캔버스 안에서
81
+ * 일어나는 수작업 편집이 이 property 에는 자동 반영되지 않는다. 이 경우 `boardProvider`
82
+ * 콜백을 같이 넘기면 send() 시점에 라이브 보드를 pull 한다.
83
+ */
84
+ @property({ type: Object })
85
+ currentBoard?: any
86
+
87
+ /**
88
+ * 라이브 보드 모델 공급자 (선택).
89
+ *
90
+ * 호스트가 things-scene 같은 캔버스를 사용 중이면 사용자가 캔버스에서 수작업 편집한
91
+ * 내용은 `currentBoard` property 에 자동 반영되지 않는다. 이 콜백을 등록하면 `send()`
92
+ * 직전에 호출해 가장 최신 보드를 pull → AI 가 항상 라이브 상태를 본다.
93
+ *
94
+ * 우선순위: boardProvider() ?? currentBoard.
95
+ */
96
+ @property({ attribute: false })
97
+ boardProvider?: () => any
98
+
99
+ /**
100
+ * 모델러에서 현재 선택된 컴포넌트의 `refid` 목록.
101
+ *
102
+ * refid 는 things-scene 이 모든 컴포넌트에 자동 발급하는 universal numeric handle.
103
+ * 호스트가 매 render 마다 `.selectedRefids=${...}` 로 갱신. AI 한테 selection 정보로
104
+ * 그대로 전달되어 "선택한" / "selected" / "this" 류 지시어 해석에 사용.
105
+ */
106
+ @property({ type: Array, attribute: false })
107
+ selectedRefids: number[] = []
108
+
109
+ /** board-import registry scopes. */
110
+ @property({ type: Array })
111
+ scopes?: string[]
112
+
113
+ @property({ type: Array, attribute: 'known-types' })
114
+ knownTypes?: string[]
115
+
116
+ @property({ type: Array })
117
+ categories?: string[]
118
+
119
+ /**
120
+ * 컴포넌트 type 별 유효 속성 스킴.
121
+ * 형태: [{ type, description?, group?, properties? }]
122
+ * LLM 이 정확한 컴포넌트를 만들도록 mutation input 으로 forwarding.
123
+ */
124
+ @property({ type: Array, attribute: false })
125
+ componentSchemas?: Array<{ type: string; description?: string; group?: string; properties?: Record<string, any> }>
126
+
127
+ @property({ type: String })
128
+ placeholder = '자연어로 명령하세요 (Shift+Enter 줄바꿈, Enter 전송)'
129
+
130
+ @state()
131
+ private lines: ChatLine[] = []
132
+
133
+ @state()
134
+ private input = ''
135
+
136
+ /** 마지막 send 실패 시 보존 — 다시 시도 버튼 클릭 시 사용 */
137
+ @state()
138
+ private lastFailedInput?: string
139
+
140
+ /** 되돌려진 patch id 들 — 한 번 revert 한 patch 는 버튼 비활성 */
141
+ @state()
142
+ private revertedPatchIds = new Set<string>()
143
+
144
+ /** mini action — 인라인 예시 토글 */
145
+ @state()
146
+ private examplesOpen = false
147
+
148
+ /** 복사 toast 표시 상태 */
149
+ @state()
150
+ private toastMessage?: string
151
+
152
+ /** 복사 confirmation feedback (메시지 idx → 짧은 시간 동안 ✓ 표시) */
153
+ @state()
154
+ private copiedIdx?: number
155
+
156
+ @state()
157
+ private busy = false
158
+
159
+ @state()
160
+ private errorMessage?: string
161
+
162
+ static styles = [
163
+ // 어플리케이션 표준 스크롤바 (--scrollbar-width / --scrollbar-thumb-color 등 호스트 변수 따름)
164
+ ScrollbarStyles,
165
+ css`
166
+ /*
167
+ Material Design 3 토큰 사용 — 사용자 테마 (라이트/다크/브랜드) 자동 반영.
168
+ 각 var() 의 두 번째 인자는 토큰 미정의 시 fallback (현 디자인 톤).
169
+ */
170
+ :host {
171
+ display: flex;
172
+ flex-direction: column;
173
+ height: 100%;
174
+ min-height: 240px;
175
+ position: relative;
176
+ font:
177
+ 400 11px / 1.55 -apple-system,
178
+ BlinkMacSystemFont,
179
+ 'Inter',
180
+ 'Pretendard',
181
+ 'Segoe UI',
182
+ Roboto,
183
+ sans-serif;
184
+ letter-spacing: -0.005em;
185
+ color: var(--md-sys-color-on-surface, #0f172a);
186
+ background: var(--md-sys-color-surface, #ffffff);
187
+ }
188
+
189
+ /* ── Messages ─────────────────────────────────────── */
190
+ /* 스크롤바는 ScrollbarStyles (위쪽 styles 배열) 가 처리 — 어플리케이션 표준 따름 */
191
+ .messages {
192
+ flex: 1;
193
+ overflow-y: auto;
194
+ padding: 16px 20px 8px;
195
+ display: flex;
196
+ flex-direction: column;
197
+ gap: 2px;
198
+ scroll-behavior: smooth;
199
+ }
200
+
201
+ /* ── Empty state — onboarding 가이드 ───────────────── */
202
+ .empty {
203
+ margin: auto;
204
+ text-align: left;
205
+ padding: 24px 16px;
206
+ width: 100%;
207
+ max-width: 360px;
208
+ display: flex;
209
+ flex-direction: column;
210
+ gap: 16px;
211
+ }
212
+ .empty .header {
213
+ text-align: center;
214
+ padding: 8px 0 4px;
215
+ }
216
+ .empty .icon {
217
+ font-size: 22px;
218
+ opacity: 0.4;
219
+ margin-bottom: 8px;
220
+ color: var(--md-sys-color-primary, currentColor);
221
+ display: block;
222
+ }
223
+ .empty .title {
224
+ color: var(--md-sys-color-on-surface, #0f172a);
225
+ font-size: 12.5px;
226
+ font-weight: 600;
227
+ margin-bottom: 4px;
228
+ letter-spacing: -0.01em;
229
+ }
230
+ .empty .subtitle {
231
+ color: var(--md-sys-color-on-surface-variant, #475569);
232
+ font-size: 10.5px;
233
+ line-height: 1.5;
234
+ }
235
+
236
+ .empty .group {
237
+ display: flex;
238
+ flex-direction: column;
239
+ gap: 4px;
240
+ }
241
+ .empty .group-label {
242
+ font-size: 9.5px;
243
+ color: var(--md-sys-color-outline, #94a3b8);
244
+ letter-spacing: 0.06em;
245
+ text-transform: uppercase;
246
+ font-weight: 600;
247
+ padding: 0 2px;
248
+ }
249
+ .empty .example {
250
+ all: unset;
251
+ display: block;
252
+ cursor: pointer;
253
+ padding: 7px 10px;
254
+ font-size: 11px;
255
+ color: var(--md-sys-color-on-surface, #0f172a);
256
+ background: var(--md-sys-color-surface-container, #f8fafc);
257
+ border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
258
+ border-radius: 8px;
259
+ line-height: 1.5;
260
+ transition:
261
+ background 0.15s,
262
+ border-color 0.15s,
263
+ transform 0.1s;
264
+ }
265
+ .empty .example:hover {
266
+ background: var(--md-sys-color-surface-container-high, #f1f5f9);
267
+ border-color: var(--md-sys-color-outline, #cbd5e1);
268
+ }
269
+ .empty .example:active {
270
+ transform: scale(0.99);
271
+ }
272
+ .empty .example .arrow {
273
+ float: right;
274
+ opacity: 0.4;
275
+ margin-left: 8px;
276
+ font-size: 11px;
277
+ }
278
+
279
+ .empty .footer {
280
+ margin-top: 4px;
281
+ padding: 8px 4px 0;
282
+ border-top: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
283
+ color: var(--md-sys-color-outline, #94a3b8);
284
+ font-size: 10px;
285
+ line-height: 1.6;
286
+ text-align: center;
287
+ }
288
+ .empty .footer .badge {
289
+ display: inline-block;
290
+ margin: 0 4px;
291
+ padding: 1px 7px;
292
+ background: var(--md-sys-color-surface-container, #f8fafc);
293
+ border-radius: 8px;
294
+ font-size: 9.5px;
295
+ color: var(--md-sys-color-on-surface-variant, #64748b);
296
+ }
297
+
298
+ /* ── Message label (sender hint) ──────────────────── */
299
+ .msg-label {
300
+ font-size: 9.5px;
301
+ color: var(--md-sys-color-outline, #94a3b8);
302
+ letter-spacing: 0.05em;
303
+ font-weight: 500;
304
+ text-transform: uppercase;
305
+ margin: 10px 0 3px 4px;
306
+ align-self: flex-start;
307
+ }
308
+ .msg-label:first-child {
309
+ margin-top: 0;
310
+ }
311
+
312
+ /* ── Message wrapper (메시지 + hover 액션) ────────── */
313
+ .msg-wrap {
314
+ display: flex;
315
+ flex-direction: column;
316
+ width: fit-content;
317
+ max-width: min(78%, 480px);
318
+ margin: 2px 0;
319
+ }
320
+ .msg-wrap.user {
321
+ align-self: flex-end;
322
+ align-items: flex-end;
323
+ }
324
+ .msg-wrap.assistant {
325
+ align-self: flex-start;
326
+ align-items: flex-start;
327
+ }
328
+ .msg-wrap.system {
329
+ align-self: center;
330
+ align-items: center;
331
+ max-width: 100%;
332
+ }
333
+
334
+ /* ── Message bubble ───────────────────────────────── */
335
+ .msg {
336
+ display: inline-block;
337
+ width: fit-content;
338
+ max-width: 100%;
339
+ padding: 6px 10px;
340
+ border-radius: 12px;
341
+ white-space: normal;
342
+ overflow-wrap: anywhere;
343
+ font-size: 11px;
344
+ line-height: 1.55;
345
+ }
346
+
347
+ .msg.user {
348
+ align-self: flex-end;
349
+ background: var(--md-sys-color-primary, #1e293b);
350
+ color: var(--md-sys-color-on-primary, #f8fafc);
351
+ border-radius: 12px 12px 3px 12px;
352
+ margin-top: 6px;
353
+ }
354
+
355
+ .msg.assistant {
356
+ align-self: flex-start;
357
+ background: var(--md-sys-color-surface-container, #f8fafc);
358
+ color: var(--md-sys-color-on-surface, #0f172a);
359
+ border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
360
+ border-radius: 12px 12px 12px 3px;
361
+ }
362
+
363
+ /* ── Markdown 렌더 (AI 응답) ──────────────────────── */
364
+ .md-body {
365
+ display: block;
366
+ }
367
+ .md-body > :first-child {
368
+ margin-top: 0;
369
+ }
370
+ .md-body > :last-child {
371
+ margin-bottom: 0;
372
+ }
373
+ .md-body p {
374
+ margin: 0 0 6px;
375
+ line-height: 1.55;
376
+ }
377
+ .md-body ul,
378
+ .md-body ol {
379
+ margin: 4px 0 6px;
380
+ padding-left: 18px;
381
+ }
382
+ .md-body li {
383
+ margin: 1px 0;
384
+ line-height: 1.5;
385
+ }
386
+ .md-body li > p {
387
+ margin: 0;
388
+ }
389
+ .md-body strong {
390
+ font-weight: 600;
391
+ }
392
+ .md-body em {
393
+ font-style: italic;
394
+ }
395
+ .md-body code {
396
+ font-family:
397
+ 'SF Mono',
398
+ Menlo,
399
+ Monaco,
400
+ Consolas,
401
+ monospace;
402
+ font-size: 10px;
403
+ padding: 1px 4px;
404
+ background: var(--md-sys-color-surface-container-high, rgba(15, 23, 42, 0.06));
405
+ border-radius: 3px;
406
+ }
407
+ .md-body pre {
408
+ margin: 5px 0;
409
+ padding: 6px 8px;
410
+ background: var(--md-sys-color-surface-container-high, rgba(15, 23, 42, 0.06));
411
+ border-radius: 5px;
412
+ overflow-x: auto;
413
+ font-size: 10px;
414
+ line-height: 1.5;
415
+ }
416
+ /* user 메시지(어두운 배경) 위에서는 code/pre 의 contrast 보정 */
417
+ .msg.user .md-body code,
418
+ .msg.user .md-body pre {
419
+ background: rgba(255, 255, 255, 0.15);
420
+ }
421
+ .msg.user .md-body a {
422
+ color: inherit;
423
+ text-decoration: underline;
424
+ text-underline-offset: 2px;
425
+ }
426
+ .msg.user .md-body blockquote {
427
+ border-left-color: rgba(255, 255, 255, 0.3);
428
+ color: rgba(255, 255, 255, 0.85);
429
+ }
430
+ .msg.user .md-body hr {
431
+ border-top-color: rgba(255, 255, 255, 0.2);
432
+ }
433
+ .md-body pre code {
434
+ padding: 0;
435
+ background: transparent;
436
+ font-size: inherit;
437
+ }
438
+ .md-body a {
439
+ color: var(--md-sys-color-primary, #1e293b);
440
+ text-decoration: underline;
441
+ text-underline-offset: 2px;
442
+ }
443
+ .md-body a:hover {
444
+ text-decoration: none;
445
+ }
446
+ .md-body h1,
447
+ .md-body h2,
448
+ .md-body h3,
449
+ .md-body h4 {
450
+ margin: 6px 0 3px;
451
+ font-weight: 600;
452
+ line-height: 1.4;
453
+ }
454
+ .md-body h1 { font-size: 13px; }
455
+ .md-body h2 { font-size: 12px; }
456
+ .md-body h3,
457
+ .md-body h4 { font-size: 11.5px; }
458
+ .md-body blockquote {
459
+ margin: 4px 0;
460
+ padding: 1px 8px;
461
+ border-left: 2px solid var(--md-sys-color-outline-variant, #cbd5e1);
462
+ color: var(--md-sys-color-on-surface-variant, #64748b);
463
+ }
464
+ .md-body hr {
465
+ border: 0;
466
+ border-top: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
467
+ margin: 6px 0;
468
+ }
469
+ .md-body table {
470
+ border-collapse: collapse;
471
+ margin: 4px 0;
472
+ font-size: 10px;
473
+ }
474
+ .md-body th,
475
+ .md-body td {
476
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
477
+ padding: 2px 6px;
478
+ }
479
+ .md-body th {
480
+ background: var(--md-sys-color-surface-container, #f8fafc);
481
+ font-weight: 600;
482
+ }
483
+
484
+ .msg.system {
485
+ align-self: center;
486
+ background: transparent;
487
+ color: var(--md-sys-color-outline, #94a3b8);
488
+ font-size: 10px;
489
+ font-style: italic;
490
+ max-width: 100%;
491
+ padding: 3px 8px;
492
+ margin: 3px 0;
493
+ border: 0;
494
+ }
495
+
496
+ .msg.pending {
497
+ opacity: 0.5;
498
+ }
499
+ .msg.pending::after {
500
+ content: '';
501
+ display: inline-block;
502
+ width: 6px;
503
+ height: 6px;
504
+ margin-left: 6px;
505
+ border-radius: 50%;
506
+ background: currentColor;
507
+ opacity: 0.4;
508
+ animation: pulse 1.2s ease-in-out infinite;
509
+ }
510
+ @keyframes pulse {
511
+ 0%, 100% { opacity: 0.2; transform: scale(0.85); }
512
+ 50% { opacity: 0.7; transform: scale(1.1); }
513
+ }
514
+
515
+ /* ── Patch indicator + 인라인 되돌리기 ─────────────── */
516
+ .summary {
517
+ display: inline-flex;
518
+ align-items: center;
519
+ gap: 6px;
520
+ margin-top: 6px;
521
+ padding: 3px 4px 3px 8px;
522
+ background: var(--md-sys-color-surface-container-high, #f1f5f9);
523
+ border-radius: 6px;
524
+ font-size: 10px;
525
+ color: var(--md-sys-color-on-surface-variant, #475569);
526
+ line-height: 1;
527
+ }
528
+ .summary md-icon {
529
+ --md-icon-size: 13px;
530
+ color: var(--md-sys-color-tertiary, #10b981);
531
+ }
532
+ .summary.reverted {
533
+ opacity: 0.6;
534
+ }
535
+ .summary.reverted md-icon {
536
+ color: var(--md-sys-color-outline, #94a3b8);
537
+ }
538
+ .summary .summary-text {
539
+ letter-spacing: 0.01em;
540
+ }
541
+ .summary .revert-btn {
542
+ height: 20px;
543
+ padding: 0 8px;
544
+ background: transparent;
545
+ border: 0;
546
+ border-radius: 4px;
547
+ cursor: pointer;
548
+ font-family: inherit;
549
+ letter-spacing: inherit;
550
+ font-size: 10px;
551
+ color: var(--md-sys-color-on-surface-variant, #475569);
552
+ line-height: 1;
553
+ transition: background 0.12s, color 0.12s;
554
+ width: auto;
555
+ }
556
+ .summary .revert-btn:hover:not(:disabled) {
557
+ background: var(--md-sys-color-surface, #ffffff);
558
+ color: var(--md-sys-color-on-surface, #0f172a);
559
+ }
560
+ .summary.reverted .revert-btn,
561
+ .summary .revert-btn:disabled {
562
+ display: none;
563
+ }
564
+
565
+ /* ── Follow-up question ───────────────────────────── */
566
+ .followup {
567
+ font-size: 10.5px;
568
+ color: var(--md-sys-color-on-secondary-container, #92400e);
569
+ background: var(--md-sys-color-secondary-container, #fffbeb);
570
+ border-left: 2px solid var(--md-sys-color-secondary, #f59e0b);
571
+ padding: 5px 8px;
572
+ border-radius: 0 5px 5px 0;
573
+ margin-top: 6px;
574
+ line-height: 1.5;
575
+ }
576
+
577
+ /* ── Tool usages fold-able 박스 — AI 도구 호출 trace ───── */
578
+ .tool-usages {
579
+ margin-top: 6px;
580
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
581
+ border-radius: 6px;
582
+ background: var(--md-sys-color-surface-container-low, #f8fafc);
583
+ overflow: hidden;
584
+ font-size: 10.5px;
585
+ }
586
+ .tool-usages-header {
587
+ display: flex;
588
+ align-items: center;
589
+ gap: 6px;
590
+ width: 100%;
591
+ padding: 5px 8px;
592
+ background: transparent;
593
+ border: 0;
594
+ cursor: pointer;
595
+ color: var(--md-sys-color-on-surface-variant, #475569);
596
+ font-family: inherit;
597
+ font-size: inherit;
598
+ letter-spacing: inherit;
599
+ text-align: left;
600
+ }
601
+ .tool-usages-header:hover {
602
+ background: var(--md-sys-color-surface-container, #f1f5f9);
603
+ }
604
+ .tool-usages-icon,
605
+ .tool-usages-chevron {
606
+ --md-icon-size: 14px;
607
+ flex-shrink: 0;
608
+ }
609
+ .tool-usages-summary {
610
+ flex: 1;
611
+ font-weight: 500;
612
+ }
613
+ .tool-usages-counts {
614
+ color: var(--md-sys-color-outline, #94a3b8);
615
+ font-weight: 400;
616
+ font-size: 10px;
617
+ }
618
+ .tool-usages-list {
619
+ list-style: none;
620
+ margin: 0;
621
+ padding: 0 8px 8px;
622
+ display: flex;
623
+ flex-direction: column;
624
+ gap: 6px;
625
+ }
626
+ .tool-usage-item {
627
+ border-left: 2px solid var(--md-sys-color-outline-variant, #cbd5e1);
628
+ padding: 4px 8px 4px 8px;
629
+ background: var(--md-sys-color-surface, #ffffff);
630
+ border-radius: 0 4px 4px 0;
631
+ }
632
+ .tool-usage-item.kind-read {
633
+ border-left-color: var(--md-sys-color-tertiary, #10b981);
634
+ }
635
+ .tool-usage-item.kind-write {
636
+ border-left-color: var(--md-sys-color-primary, #1e293b);
637
+ }
638
+ .tool-usage-item.kind-unknown {
639
+ border-left-color: var(--md-sys-color-error, #b91c1c);
640
+ }
641
+ .tool-usage-head {
642
+ display: flex;
643
+ align-items: center;
644
+ gap: 6px;
645
+ margin-bottom: 3px;
646
+ }
647
+ .tool-usage-step {
648
+ color: var(--md-sys-color-outline, #94a3b8);
649
+ font-size: 10px;
650
+ }
651
+ .tool-usage-name {
652
+ font-weight: 600;
653
+ color: var(--md-sys-color-on-surface, #0f172a);
654
+ font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
655
+ font-size: 10.5px;
656
+ }
657
+ .tool-usage-kind {
658
+ font-size: 9.5px;
659
+ padding: 1px 5px;
660
+ border-radius: 3px;
661
+ text-transform: uppercase;
662
+ letter-spacing: 0.05em;
663
+ font-weight: 600;
664
+ }
665
+ .tool-usage-kind.kind-read {
666
+ color: var(--md-sys-color-on-tertiary, #ffffff);
667
+ background: var(--md-sys-color-tertiary, #10b981);
668
+ }
669
+ .tool-usage-kind.kind-write {
670
+ color: var(--md-sys-color-on-primary, #ffffff);
671
+ background: var(--md-sys-color-primary, #1e293b);
672
+ }
673
+ .tool-usage-kind.kind-unknown {
674
+ color: var(--md-sys-color-on-error, #ffffff);
675
+ background: var(--md-sys-color-error, #b91c1c);
676
+ }
677
+ .tool-usage-args,
678
+ .tool-usage-result {
679
+ margin: 2px 0 0;
680
+ padding: 4px 6px;
681
+ background: var(--md-sys-color-surface-container-high, rgba(15, 23, 42, 0.04));
682
+ border-radius: 3px;
683
+ font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
684
+ font-size: 10px;
685
+ line-height: 1.4;
686
+ white-space: pre-wrap;
687
+ overflow-x: auto;
688
+ color: var(--md-sys-color-on-surface, #0f172a);
689
+ max-height: 180px;
690
+ overflow-y: auto;
691
+ }
692
+ .tool-usage-args {
693
+ border-left: 2px solid var(--md-sys-color-outline-variant, #cbd5e1);
694
+ }
695
+ .tool-usage-result {
696
+ border-left: 2px solid var(--md-sys-color-outline, #94a3b8);
697
+ }
698
+
699
+ /* ── Hover actions (assistant 메시지만, Material icon button) ── */
700
+ .msg-actions {
701
+ display: flex;
702
+ gap: 2px;
703
+ margin-top: 2px;
704
+ padding: 0;
705
+ opacity: 0;
706
+ transition: opacity 0.15s;
707
+ pointer-events: none;
708
+ }
709
+ .msg-wrap:hover .msg-actions,
710
+ .msg-wrap:focus-within .msg-actions {
711
+ opacity: 1;
712
+ pointer-events: auto;
713
+ }
714
+ .msg-actions .msg-action {
715
+ width: 22px;
716
+ height: 22px;
717
+ padding: 0;
718
+ background: transparent;
719
+ border: 0;
720
+ border-radius: 5px;
721
+ cursor: pointer;
722
+ display: inline-flex;
723
+ align-items: center;
724
+ justify-content: center;
725
+ color: var(--md-sys-color-outline, #94a3b8);
726
+ transition:
727
+ background 0.12s,
728
+ color 0.12s;
729
+ }
730
+ .msg-actions .msg-action:hover:not(:disabled) {
731
+ background: var(--md-sys-color-surface-container-high, #e2e8f0);
732
+ color: var(--md-sys-color-on-surface, #0f172a);
733
+ }
734
+ .msg-actions .msg-action:disabled {
735
+ opacity: 0.4;
736
+ cursor: default;
737
+ }
738
+ .msg-actions .msg-action.confirmed {
739
+ color: var(--md-sys-color-tertiary, #10b981);
740
+ }
741
+ .msg-actions .msg-action md-icon {
742
+ --md-icon-size: 14px;
743
+ }
744
+
745
+ /* ── Error banner ─────────────────────────────────── */
746
+ .error {
747
+ color: var(--md-sys-color-on-error-container, #b91c1c);
748
+ padding: 7px 11px;
749
+ background: var(--md-sys-color-error-container, #fef2f2);
750
+ border: 1px solid var(--md-sys-color-error, #fee2e2);
751
+ border-radius: 8px;
752
+ margin: 0 16px 8px;
753
+ font-size: 10.5px;
754
+ line-height: 1.5;
755
+ display: flex;
756
+ align-items: center;
757
+ justify-content: space-between;
758
+ gap: 8px;
759
+ }
760
+ .error .error-text {
761
+ flex: 1;
762
+ }
763
+ .error .retry-btn {
764
+ flex-shrink: 0;
765
+ padding: 3px 8px;
766
+ font-size: 10px;
767
+ font-family: inherit;
768
+ letter-spacing: inherit;
769
+ background: var(--md-sys-color-error, #b91c1c);
770
+ color: var(--md-sys-color-on-error, #ffffff);
771
+ border: 0;
772
+ border-radius: 6px;
773
+ cursor: pointer;
774
+ transition: opacity 0.15s;
775
+ width: auto;
776
+ height: auto;
777
+ }
778
+ .error .retry-btn:hover {
779
+ opacity: 0.88;
780
+ }
781
+
782
+ /* ── Composer (actions + input 통합 영역) ──────────── */
783
+ .composer {
784
+ border-top: 1px solid var(--md-sys-color-outline-variant, #f1f5f9);
785
+ background: var(--md-sys-color-surface, #ffffff);
786
+ display: flex;
787
+ flex-direction: column;
788
+ }
789
+
790
+ /* ── Mini action row (메시지 시작 후) ──────────────── */
791
+ .actions-row {
792
+ display: flex;
793
+ gap: 6px;
794
+ padding: 10px 16px 6px;
795
+ flex-wrap: wrap;
796
+ align-items: center;
797
+ }
798
+ /* Material 3 assist-chip 톤 — height 24, font label-small */
799
+ .action {
800
+ display: inline-flex;
801
+ align-items: center;
802
+ justify-content: center;
803
+ gap: 5px;
804
+ height: 24px;
805
+ padding: 0 10px;
806
+ font-size: 11px;
807
+ font-family: inherit;
808
+ letter-spacing: inherit;
809
+ line-height: 1;
810
+ color: var(--md-sys-color-on-surface-variant, #475569);
811
+ background: transparent;
812
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
813
+ border-radius: 8px;
814
+ cursor: pointer;
815
+ transition:
816
+ background 0.15s,
817
+ border-color 0.15s,
818
+ color 0.15s;
819
+ width: auto;
820
+ vertical-align: middle;
821
+ }
822
+ .action:hover {
823
+ background: var(--md-sys-color-surface-container, #f8fafc);
824
+ border-color: var(--md-sys-color-outline, #cbd5e1);
825
+ color: var(--md-sys-color-on-surface, #0f172a);
826
+ }
827
+ .action:active {
828
+ transform: scale(0.97);
829
+ }
830
+ .action md-icon {
831
+ --md-icon-size: 14px;
832
+ opacity: 0.7;
833
+ }
834
+
835
+ /* ── Inline examples (action row 의 "예시" 토글 시) ──── */
836
+ .inline-examples {
837
+ padding: 4px 16px 0;
838
+ display: flex;
839
+ flex-direction: column;
840
+ gap: 3px;
841
+ }
842
+ .inline-examples .ex-row {
843
+ display: flex;
844
+ flex-wrap: wrap;
845
+ gap: 4px;
846
+ }
847
+ .inline-examples .ex-row .ex-label {
848
+ font-size: 9px;
849
+ color: var(--md-sys-color-outline, #94a3b8);
850
+ letter-spacing: 0.05em;
851
+ text-transform: uppercase;
852
+ margin-right: 2px;
853
+ align-self: center;
854
+ font-weight: 600;
855
+ }
856
+ .inline-examples .ex {
857
+ all: unset;
858
+ cursor: pointer;
859
+ padding: 2px 8px;
860
+ font-size: 10px;
861
+ color: var(--md-sys-color-on-surface, #0f172a);
862
+ background: var(--md-sys-color-surface-container, #f8fafc);
863
+ border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
864
+ border-radius: 10px;
865
+ transition: background 0.15s;
866
+ }
867
+ .inline-examples .ex:hover {
868
+ background: var(--md-sys-color-surface-container-high, #f1f5f9);
869
+ }
870
+
871
+ /* ── Input area ───────────────────────────────────── */
872
+ /* border-top 은 .composer 가 담당 (actions-row 가 같은 wrapper 안) */
873
+ .input-row {
874
+ display: flex;
875
+ gap: 10px;
876
+ padding: 6px 16px 14px;
877
+ background: transparent;
878
+ align-items: flex-end;
879
+ }
880
+ /* 메시지 시작 전 — actions-row 가 없을 때는 input-row 가 직접 border-top */
881
+ .composer.no-actions .input-row {
882
+ padding-top: 14px;
883
+ }
884
+
885
+ textarea {
886
+ flex: 1;
887
+ padding: 8px 12px;
888
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
889
+ border-radius: 12px;
890
+ font: inherit;
891
+ letter-spacing: inherit;
892
+ resize: none;
893
+ min-height: 36px;
894
+ max-height: 140px;
895
+ outline: none;
896
+ transition:
897
+ border-color 0.2s,
898
+ box-shadow 0.2s;
899
+ background: var(--md-sys-color-surface, #ffffff);
900
+ color: var(--md-sys-color-on-surface, #0f172a);
901
+ }
902
+ textarea::placeholder {
903
+ color: var(--md-sys-color-outline, #94a3b8);
904
+ }
905
+ textarea:focus {
906
+ border-color: var(--md-sys-color-outline, #cbd5e1);
907
+ box-shadow: 0 0 0 3px var(--md-sys-color-primary-container, rgba(15, 23, 42, 0.08));
908
+ }
909
+ textarea:disabled {
910
+ background: var(--md-sys-color-surface-container, #f8fafc);
911
+ color: var(--md-sys-color-outline, #94a3b8);
912
+ cursor: not-allowed;
913
+ }
914
+
915
+ /* ── Send button — ChatGPT 스타일 원형 ↑ (.input-row 안의 button 만) ─── */
916
+ .input-row > button {
917
+ width: 32px;
918
+ height: 32px;
919
+ padding: 0;
920
+ background: var(--md-sys-color-primary, #1e293b);
921
+ color: var(--md-sys-color-on-primary, #ffffff);
922
+ border: 0;
923
+ border-radius: 50%;
924
+ cursor: pointer;
925
+ display: flex;
926
+ align-items: center;
927
+ justify-content: center;
928
+ transition:
929
+ background 0.15s,
930
+ transform 0.1s,
931
+ opacity 0.15s;
932
+ flex-shrink: 0;
933
+ align-self: flex-end;
934
+ margin-bottom: 2px;
935
+ }
936
+ .input-row > button:disabled {
937
+ background: var(--md-sys-color-surface-container-highest, #e2e8f0);
938
+ color: var(--md-sys-color-outline, #94a3b8);
939
+ cursor: not-allowed;
940
+ }
941
+ .input-row > button:hover:not(:disabled) {
942
+ opacity: 0.88;
943
+ }
944
+ .input-row > button:active:not(:disabled) {
945
+ transform: scale(0.94);
946
+ }
947
+ .input-row > button md-icon {
948
+ --md-icon-size: 18px;
949
+ }
950
+ .input-row > button .spinner {
951
+ width: 12px;
952
+ height: 12px;
953
+ border: 1.8px solid currentColor;
954
+ border-right-color: transparent;
955
+ border-radius: 50%;
956
+ animation: spin 0.7s linear infinite;
957
+ }
958
+ @keyframes spin {
959
+ to {
960
+ transform: rotate(360deg);
961
+ }
962
+ }
963
+ `
964
+ ]
965
+
966
+ connectedCallback() {
967
+ super.connectedCallback()
968
+ if (this.sessionId) {
969
+ this.loadHistory()
970
+ }
971
+ }
972
+
973
+ updated(changed: Map<string, any>) {
974
+ if (changed.has('sessionId') && this.sessionId && !changed.get('sessionId')) {
975
+ this.loadHistory()
976
+ }
977
+ // 자동 스크롤
978
+ const ms = this.renderRoot.querySelector('.messages') as HTMLElement | null
979
+ if (ms) ms.scrollTop = ms.scrollHeight
980
+ }
981
+
982
+ private async loadHistory() {
983
+ if (!this.sessionId) return
984
+ try {
985
+ const result = await client.query({
986
+ query: CHAT_MESSAGES_QUERY,
987
+ variables: { sessionId: this.sessionId, limit: 200, offset: 0 },
988
+ fetchPolicy: 'network-only'
989
+ })
990
+ const msgs = result.data?.chatMessages ?? []
991
+ this.lines = msgs.map((m: any) => ({
992
+ role: m.role,
993
+ content: m.content,
994
+ patchId: m.relatedPatchId ?? undefined,
995
+ toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined
996
+ }))
997
+ } catch (e: any) {
998
+ this.errorMessage = `${i18next.t('board-ai.text.failed-to-load-history')}: ${e.message ?? e}`
999
+ }
1000
+ }
1001
+
1002
+ render() {
1003
+ const hasMessages = this.lines.length > 0
1004
+ return html`
1005
+ ${this.toastMessage
1006
+ ? html`<div class="toast show">${this.toastMessage}</div>`
1007
+ : nothing}
1008
+ <div class="messages">
1009
+ ${!hasMessages ? this.renderEmpty() : nothing}
1010
+ ${this.lines.map((line, idx) => {
1011
+ const prev = this.lines[idx - 1]
1012
+ const showLabel = line.role === 'assistant' && (!prev || prev.role !== 'assistant')
1013
+ const content = (line.content ?? '').trim()
1014
+ const isMarkdown = line.role !== 'system' && content.length > 0
1015
+ const showActions = line.role !== 'system' && !line.pending && content.length > 0
1016
+ return html`
1017
+ ${showLabel
1018
+ ? html`<div class="msg-label">${i18next.t('board-ai.label.ai')}</div>`
1019
+ : nothing}
1020
+ <div class="msg-wrap ${line.role}">
1021
+ <div class="msg ${line.role} ${line.pending ? 'pending' : ''}">
1022
+ ${isMarkdown
1023
+ ? html`<div class="md-body">${unsafeHTML(renderMarkdown(content))}</div>`
1024
+ : content}
1025
+ ${line.patchId
1026
+ ? html`
1027
+ <div class="summary ${this.revertedPatchIds.has(line.patchId) ? 'reverted' : ''}">
1028
+ <md-icon>check_circle</md-icon>
1029
+ <span class="summary-text">
1030
+ ${this.revertedPatchIds.has(line.patchId)
1031
+ ? i18next.t('board-ai.text.reverted')
1032
+ : i18next.t('board-ai.text.applied-changes')}
1033
+ </span>
1034
+ <button
1035
+ class="revert-btn"
1036
+ ?disabled=${this.revertedPatchIds.has(line.patchId)}
1037
+ @click=${() => line.patchId && this.onRevertClick(line.patchId)}>
1038
+ ${i18next.t('board-ai.button.revert')}
1039
+ </button>
1040
+ </div>
1041
+ `
1042
+ : nothing}
1043
+ ${line.followUp
1044
+ ? html`<div class="followup">${unsafeHTML(renderMarkdown(line.followUp.trim()))}</div>`
1045
+ : nothing}
1046
+ ${line.toolUsages && line.toolUsages.length > 0
1047
+ ? this.renderToolUsages(idx, line)
1048
+ : nothing}
1049
+ </div>
1050
+ ${showActions ? this.renderMessageActions(idx, line, content) : nothing}
1051
+ </div>
1052
+ `
1053
+ })}
1054
+ </div>
1055
+ ${this.errorMessage
1056
+ ? html`
1057
+ <div class="error">
1058
+ <span class="error-text">${this.errorMessage}</span>
1059
+ ${this.lastFailedInput
1060
+ ? html`
1061
+ <button class="retry-btn" @click=${this.retryLastSend}>
1062
+ ${i18next.t('board-ai.button.retry')}
1063
+ </button>
1064
+ `
1065
+ : nothing}
1066
+ </div>
1067
+ `
1068
+ : nothing}
1069
+ <div class="composer ${hasMessages ? '' : 'no-actions'}">
1070
+ ${hasMessages
1071
+ ? html`
1072
+ <div class="actions-row">
1073
+ <button
1074
+ class="action"
1075
+ @click=${this.clearChat}
1076
+ title=${i18next.t('board-ai.text.clear-chat-tooltip')}>
1077
+ <md-icon>refresh</md-icon>
1078
+ ${i18next.t('board-ai.button.new-conversation')}
1079
+ </button>
1080
+ <button
1081
+ class="action"
1082
+ @click=${() => (this.examplesOpen = !this.examplesOpen)}
1083
+ title=${i18next.t('board-ai.text.show-examples-tooltip')}>
1084
+ <md-icon>${this.examplesOpen ? 'close' : 'lightbulb'}</md-icon>
1085
+ ${this.examplesOpen
1086
+ ? i18next.t('board-ai.button.close')
1087
+ : i18next.t('board-ai.button.examples')}
1088
+ </button>
1089
+ </div>
1090
+ ${this.examplesOpen ? this.renderInlineExamples() : nothing}
1091
+ `
1092
+ : nothing}
1093
+ <div class="input-row">
1094
+ <textarea
1095
+ .value=${this.input}
1096
+ @input=${(e: any) => (this.input = e.target.value)}
1097
+ @keydown=${this.onKeyDown}
1098
+ ?disabled=${this.busy}
1099
+ rows="1"
1100
+ placeholder=${this.placeholder ?? i18next.t('board-ai.text.input-placeholder')}></textarea>
1101
+ <button
1102
+ ?disabled=${this.busy || !this.input.trim()}
1103
+ @click=${this.send}
1104
+ title=${i18next.t('board-ai.text.send-tooltip')}>
1105
+ ${this.busy
1106
+ ? html`<span class="spinner"></span>`
1107
+ : html`<md-icon>arrow_upward</md-icon>`}
1108
+ </button>
1109
+ </div>
1110
+ </div>
1111
+ `
1112
+ }
1113
+
1114
+ private onKeyDown(e: KeyboardEvent) {
1115
+ if (e.key === 'Enter' && !e.shiftKey && !this.busy) {
1116
+ e.preventDefault()
1117
+ this.send()
1118
+ }
1119
+ }
1120
+
1121
+ // ── Empty state with onboarding guide ──────────────────────────
1122
+
1123
+ /** 예시 그룹 — i18n 키. localize mixin 이 언어 변경 시 자동 재렌더. */
1124
+ private static readonly EXAMPLE_GROUPS: Array<{
1125
+ labelKey: string
1126
+ itemKeys: string[]
1127
+ }> = [
1128
+ {
1129
+ labelKey: 'board-ai.label.create',
1130
+ itemKeys: [
1131
+ 'board-ai.example.create-monitoring-dashboard',
1132
+ 'board-ai.example.create-welcome-screen'
1133
+ ]
1134
+ },
1135
+ {
1136
+ labelKey: 'board-ai.label.edit',
1137
+ itemKeys: [
1138
+ 'board-ai.example.edit-align-distribute',
1139
+ 'board-ai.example.edit-resize-board'
1140
+ ]
1141
+ },
1142
+ {
1143
+ labelKey: 'board-ai.label.style',
1144
+ itemKeys: [
1145
+ 'board-ai.example.style-dark-mode',
1146
+ 'board-ai.example.style-rounded-shadow'
1147
+ ]
1148
+ }
1149
+ ]
1150
+
1151
+ private renderEmpty() {
1152
+ return html`
1153
+ <div class="empty">
1154
+ <div class="header">
1155
+ <md-icon class="icon">auto_awesome</md-icon>
1156
+ <div class="title">${i18next.t('board-ai.text.empty-title')}</div>
1157
+ <div class="subtitle">${i18next.t('board-ai.text.empty-subtitle')}</div>
1158
+ </div>
1159
+
1160
+ ${OxBoardAIChat.EXAMPLE_GROUPS.map(
1161
+ group => html`
1162
+ <div class="group">
1163
+ <div class="group-label">${i18next.t(group.labelKey)}</div>
1164
+ ${group.itemKeys.map(key => {
1165
+ const text = i18next.t(key)
1166
+ return html`
1167
+ <button class="example" @click=${() => this.useExample(text)}>
1168
+ ${text}<span class="arrow">→</span>
1169
+ </button>
1170
+ `
1171
+ })}
1172
+ </div>
1173
+ `
1174
+ )}
1175
+
1176
+ <div class="footer">
1177
+ <span class="badge">${i18next.t('board-ai.label.korean-supported')}</span>
1178
+ <span class="badge">${i18next.t('board-ai.label.multi-command')}</span>
1179
+ <span class="badge">${i18next.t('board-ai.label.review-able')}</span>
1180
+ <br />
1181
+ ${i18next.t('board-ai.text.empty-footer-notice')}
1182
+ </div>
1183
+ </div>
1184
+ `
1185
+ }
1186
+
1187
+ /** 예시 클릭 — 입력창에 채우고 포커스 (사용자가 수정 후 전송 가능) */
1188
+ private useExample(text: string) {
1189
+ this.input = text
1190
+ this.examplesOpen = false
1191
+ requestAnimationFrame(() => {
1192
+ const ta = this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null
1193
+ ta?.focus()
1194
+ ta?.setSelectionRange(text.length, text.length)
1195
+ })
1196
+ }
1197
+
1198
+ // ── Mini actions ───────────────────────────────────────────────
1199
+
1200
+ /** "새 대화" — UI 의 메시지 라인 비움 (서버 ChatSession 이력은 그대로 보존) */
1201
+ private clearChat() {
1202
+ this.lines = []
1203
+ this.input = ''
1204
+ this.errorMessage = undefined
1205
+ this.lastFailedInput = undefined
1206
+ this.examplesOpen = false
1207
+ this.revertedPatchIds = new Set()
1208
+ }
1209
+
1210
+ private renderInlineExamples() {
1211
+ return html`
1212
+ <div class="inline-examples">
1213
+ ${OxBoardAIChat.EXAMPLE_GROUPS.map(
1214
+ group => html`
1215
+ <div class="ex-row">
1216
+ <span class="ex-label">${i18next.t(group.labelKey)}</span>
1217
+ ${group.itemKeys.map(key => {
1218
+ const t = i18next.t(key)
1219
+ return html`<button class="ex" @click=${() => this.useExample(t)}>${t}</button>`
1220
+ })}
1221
+ </div>
1222
+ `
1223
+ )}
1224
+ </div>
1225
+ `
1226
+ }
1227
+
1228
+ // ── Retry (에러 발생 시) ───────────────────────────────────────
1229
+
1230
+ private async retryLastSend() {
1231
+ if (!this.lastFailedInput) return
1232
+ this.input = this.lastFailedInput
1233
+ this.errorMessage = undefined
1234
+ this.lastFailedInput = undefined
1235
+ await this.send()
1236
+ }
1237
+
1238
+ // ── Per-message hover action row ───────────────────────────────
1239
+
1240
+ /**
1241
+ * 메시지별 hover 액션 — 텍스트 chip 으로 명확.
1242
+ * user : 액션 row 자체 X (자기 메시지에 시각 노이즈 X — 편집은 input 위 "새 대화" 흐름 또는 마우스 더블클릭)
1243
+ * assistant : 복사 + 재생성 + (patch 적용된 경우만) 되돌리기
1244
+ * system : 없음
1245
+ */
1246
+ private renderMessageActions(idx: number, line: ChatLine, content: string) {
1247
+ if (line.role !== 'assistant') return nothing
1248
+
1249
+ const isCopied = this.copiedIdx === idx
1250
+ // revert 는 인라인 patch chip (메시지 안) 한 곳에만 — hover 액션에서 제외 (중복 방지).
1251
+ return html`
1252
+ <div class="msg-actions">
1253
+ <button
1254
+ class="msg-action ${isCopied ? 'confirmed' : ''}"
1255
+ title=${isCopied
1256
+ ? i18next.t('board-ai.text.copied')
1257
+ : i18next.t('board-ai.button.copy')}
1258
+ @click=${() => this.copyMessage(idx, content)}>
1259
+ <md-icon>${isCopied ? 'check' : 'content_copy'}</md-icon>
1260
+ </button>
1261
+ <button
1262
+ class="msg-action"
1263
+ ?disabled=${this.busy}
1264
+ title=${i18next.t('board-ai.button.regenerate')}
1265
+ @click=${() => this.regenerateAssistant(idx)}>
1266
+ <md-icon>refresh</md-icon>
1267
+ </button>
1268
+ </div>
1269
+ `
1270
+ }
1271
+
1272
+ /**
1273
+ * "AI 가 이런 도구를 사용했습니다" fold-able 박스.
1274
+ *
1275
+ * - 기본 닫힘 (toolUsagesOpen=false). 헤더 클릭 시 toggle.
1276
+ * - 펼쳤을 때 도구 별로 name + arguments + result + read/write 색상 배지.
1277
+ * - 디버그 / 신뢰도 향상용 — 사용자가 "왜 이렇게 답했지" 의문 가질 때 즉시 검증.
1278
+ */
1279
+ private renderToolUsages(idx: number, line: ChatLine) {
1280
+ const usages = line.toolUsages
1281
+ if (!usages || usages.length === 0) return nothing
1282
+ const open = !!line.toolUsagesOpen
1283
+ const readCount = usages.filter(u => u.kind === 'read').length
1284
+ const writeCount = usages.filter(u => u.kind === 'write').length
1285
+ const toolsLabel = i18next.t('board-ai.text.tools-used', { defaultValue: '도구 사용' })
1286
+ return html`
1287
+ <div class="tool-usages ${open ? 'open' : ''}">
1288
+ <button
1289
+ class="tool-usages-header"
1290
+ aria-expanded=${open}
1291
+ @click=${() => this.toggleToolUsages(idx)}>
1292
+ <md-icon class="tool-usages-icon">${open ? 'expand_less' : 'build'}</md-icon>
1293
+ <span class="tool-usages-summary">
1294
+ <strong>${usages.length}</strong> ${toolsLabel}
1295
+ <span class="tool-usages-counts">· read ${readCount} · write ${writeCount}</span>
1296
+ </span>
1297
+ <md-icon class="tool-usages-chevron">${open ? 'expand_less' : 'expand_more'}</md-icon>
1298
+ </button>
1299
+ ${open
1300
+ ? html`
1301
+ <ol class="tool-usages-list">
1302
+ ${usages.map(
1303
+ (u, i) => html`
1304
+ <li class="tool-usage-item kind-${u.kind}">
1305
+ <div class="tool-usage-head">
1306
+ <span class="tool-usage-step">${i + 1}.</span>
1307
+ <span class="tool-usage-name">${u.name}</span>
1308
+ <span class="tool-usage-kind kind-${u.kind}">${u.kind}</span>
1309
+ </div>
1310
+ ${u.arguments && Object.keys(u.arguments).length > 0
1311
+ ? html`<pre class="tool-usage-args">${this.formatJson(u.arguments)}</pre>`
1312
+ : nothing}
1313
+ <pre class="tool-usage-result">${this.formatJson(u.result)}</pre>
1314
+ </li>
1315
+ `
1316
+ )}
1317
+ </ol>
1318
+ `
1319
+ : nothing}
1320
+ </div>
1321
+ `
1322
+ }
1323
+
1324
+ private toggleToolUsages(idx: number) {
1325
+ const next = [...this.lines]
1326
+ const line = next[idx]
1327
+ if (!line) return
1328
+ next[idx] = { ...line, toolUsagesOpen: !line.toolUsagesOpen }
1329
+ this.lines = next
1330
+ }
1331
+
1332
+ private formatJson(value: any): string {
1333
+ try {
1334
+ return JSON.stringify(value, null, 2)
1335
+ } catch {
1336
+ return String(value)
1337
+ }
1338
+ }
1339
+
1340
+ // ── Per-message actions ────────────────────────────────────────
1341
+
1342
+ private async copyMessage(idx: number, content: string) {
1343
+ try {
1344
+ await navigator.clipboard.writeText(content)
1345
+ this.copiedIdx = idx
1346
+ this.showToast(i18next.t('board-ai.text.copied'))
1347
+ setTimeout(() => {
1348
+ if (this.copiedIdx === idx) this.copiedIdx = undefined
1349
+ }, 1500)
1350
+ } catch {
1351
+ // clipboard 권한 없거나 비-secure context — 조용히 무시
1352
+ }
1353
+ }
1354
+
1355
+ private showToast(message: string) {
1356
+ this.toastMessage = message
1357
+ setTimeout(() => {
1358
+ if (this.toastMessage === message) this.toastMessage = undefined
1359
+ }, 1500)
1360
+ }
1361
+
1362
+ /**
1363
+ * 사용자 메시지 편집 — 해당 메시지 + 이후 모두 UI 에서 제거하고 입력창에 채움.
1364
+ * 서버 영속 history 는 그대로 유지 (다음 send 시 새 메시지로 추가).
1365
+ */
1366
+ private editUserMessage(idx: number, content: string) {
1367
+ this.lines = this.lines.slice(0, idx)
1368
+ this.input = content
1369
+ requestAnimationFrame(() => {
1370
+ const ta = this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null
1371
+ ta?.focus()
1372
+ ta?.setSelectionRange(content.length, content.length)
1373
+ })
1374
+ }
1375
+
1376
+ /**
1377
+ * AI 응답 재생성 — 해당 assistant 메시지 + 이후 제거하고, 직전 user 메시지를 다시 send.
1378
+ */
1379
+ private async regenerateAssistant(idx: number) {
1380
+ if (this.busy) return
1381
+ // 직전 user 메시지 찾기
1382
+ let userIdx = idx - 1
1383
+ while (userIdx >= 0 && this.lines[userIdx].role !== 'user') userIdx--
1384
+ if (userIdx < 0) return
1385
+ const userContent = this.lines[userIdx].content
1386
+ // assistant 메시지 + 이후 제거
1387
+ this.lines = this.lines.slice(0, idx)
1388
+ this.input = userContent
1389
+ await this.send()
1390
+ }
1391
+
1392
+ // ── Patch revert ───────────────────────────────────────────────
1393
+
1394
+ private onRevertClick(patchId: string) {
1395
+ if (this.revertedPatchIds.has(patchId)) return
1396
+ this.revertedPatchIds = new Set([...this.revertedPatchIds, patchId])
1397
+ // 호스트 (board-modeller-page) 에게 revert 요청 — 호스트가 snapshot 복원 + 서버 mutation 호출
1398
+ this.dispatchEvent(
1399
+ new CustomEvent('board-edit-revert', {
1400
+ detail: { patchId },
1401
+ bubbles: true,
1402
+ composed: true
1403
+ })
1404
+ )
1405
+ }
1406
+
1407
+ private async send() {
1408
+ const text = this.input.trim()
1409
+ if (!text || this.busy) return
1410
+
1411
+ this.lines = [...this.lines, { role: 'user', content: text }]
1412
+ this.input = ''
1413
+ this.busy = true
1414
+ this.errorMessage = undefined
1415
+
1416
+ // 가짜 pending 응답 표시 (UX 부드럽게)
1417
+ const pendingIdx = this.lines.length
1418
+ this.lines = [...this.lines, { role: 'assistant', content: '', pending: true }]
1419
+
1420
+ try {
1421
+ // LLM 으로 보낼 history — user/assistant 만 (system 은 백엔드가 자동 합류)
1422
+ const history = this.lines
1423
+ .slice(0, pendingIdx) // pending 제외
1424
+ .filter(l => l.role !== 'system')
1425
+ .map(l => ({ role: l.role, content: l.content }))
1426
+
1427
+ // 라이브 보드 우선 — 호스트의 캔버스가 사용자 수작업 편집을 들고 있을 수 있음.
1428
+ // boardProvider 가 있으면 send 시점에 그것을 pull, 없으면 정적 currentBoard.
1429
+ const liveBoard = this.boardProvider ? this.boardProvider() : this.currentBoard
1430
+
1431
+ const result = await client.mutate({
1432
+ mutation: BOARD_AI_CHAT_MUTATION,
1433
+ variables: {
1434
+ input: buildChatMutationInput({
1435
+ sessionId: this.sessionId,
1436
+ history,
1437
+ liveBoard,
1438
+ scopes: this.scopes,
1439
+ knownTypes: this.knownTypes,
1440
+ categories: this.categories,
1441
+ componentSchemas: this.componentSchemas,
1442
+ selectedRefids: this.selectedRefids ?? []
1443
+ })
1444
+ }
1445
+ })
1446
+
1447
+ const out = result.data?.boardAIChat
1448
+ if (!out) throw new Error(i18next.t('board-ai.text.empty-response'))
1449
+
1450
+ // pending 자리에 실제 응답
1451
+ const replaced = [...this.lines]
1452
+ replaced[pendingIdx] = {
1453
+ role: 'assistant',
1454
+ content: out.reply,
1455
+ patchSummary: out.patch?.summary,
1456
+ followUp: out.followUp || undefined,
1457
+ patchId: out.patchId || undefined,
1458
+ toolUsages: Array.isArray(out.toolUsages) ? out.toolUsages : undefined
1459
+ }
1460
+ this.lines = replaced
1461
+
1462
+ // 호스트로 patch 이벤트 전파
1463
+ if (out.patch) {
1464
+ this.dispatchEvent(
1465
+ new CustomEvent('board-edit-patch', {
1466
+ detail: {
1467
+ patch: out.patch,
1468
+ summary: out.patch.summary,
1469
+ confidence: out.patch.confidence,
1470
+ patchId: out.patchId,
1471
+ sessionId: out.sessionId
1472
+ },
1473
+ bubbles: true,
1474
+ composed: true
1475
+ })
1476
+ )
1477
+ }
1478
+ if (out.followUp) {
1479
+ this.dispatchEvent(
1480
+ new CustomEvent('chat-followup', {
1481
+ detail: { question: out.followUp },
1482
+ bubbles: true,
1483
+ composed: true
1484
+ })
1485
+ )
1486
+ }
1487
+ } catch (e: any) {
1488
+ // pending 메시지 제거 + 에러 표시 + 마지막 입력 보존 (다시 시도용)
1489
+ this.lines = this.lines.slice(0, pendingIdx)
1490
+ this.errorMessage = e?.message ?? String(e)
1491
+ this.lastFailedInput = text
1492
+ } finally {
1493
+ this.busy = false
1494
+ }
1495
+ }
1496
+ }
1497
+
1498
+ declare global {
1499
+ interface HTMLElementTagNameMap {
1500
+ 'ox-board-ai-chat': OxBoardAIChat
1501
+ }
1502
+ }