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