@things-factory/ai-assistant 10.1.8 → 10.1.15

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 (58) hide show
  1. package/client/components/{board-ai-chat.ts → assistant-chat.ts} +597 -66
  2. package/client/components/assistant-session-toolbar.ts +70 -0
  3. package/client/components/chat-input-builder.ts +1 -1
  4. package/client/index.ts +6 -3
  5. package/client/utils/assistant-request-context.ts +21 -0
  6. package/client/utils/assistant-session-controller.ts +166 -0
  7. package/client/utils/assistant-session-transport.ts +34 -0
  8. package/dist-client/components/{board-ai-chat.d.ts → assistant-chat.d.ts} +30 -3
  9. package/dist-client/components/{board-ai-chat.js → assistant-chat.js} +616 -109
  10. package/dist-client/components/assistant-chat.js.map +1 -0
  11. package/dist-client/components/{board-ai-chat.test.js → assistant-chat.test.js} +1 -1
  12. package/dist-client/components/assistant-chat.test.js.map +1 -0
  13. package/dist-client/components/assistant-session-toolbar.d.ts +18 -0
  14. package/dist-client/components/assistant-session-toolbar.js +109 -0
  15. package/dist-client/components/assistant-session-toolbar.js.map +1 -0
  16. package/dist-client/components/chat-input-builder.js +1 -1
  17. package/dist-client/components/chat-input-builder.js.map +1 -1
  18. package/dist-client/index.d.ts +6 -3
  19. package/dist-client/index.js +6 -3
  20. package/dist-client/index.js.map +1 -1
  21. package/dist-client/tsconfig.tsbuildinfo +1 -1
  22. package/dist-client/utils/assistant-request-context.d.ts +10 -0
  23. package/dist-client/utils/assistant-request-context.js +20 -0
  24. package/dist-client/utils/assistant-request-context.js.map +1 -0
  25. package/dist-client/utils/assistant-session-controller.d.ts +49 -0
  26. package/dist-client/utils/assistant-session-controller.js +165 -0
  27. package/dist-client/utils/assistant-session-controller.js.map +1 -0
  28. package/dist-client/utils/assistant-session-transport.d.ts +3 -0
  29. package/dist-client/utils/assistant-session-transport.js +39 -0
  30. package/dist-client/utils/assistant-session-transport.js.map +1 -0
  31. package/dist-server/service/assistant-chat-resolver.js +20 -0
  32. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  33. package/dist-server/service/chat-session/chat-session.js +1 -1
  34. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  35. package/dist-server/service/chat-session/session-anchor.d.ts +1 -1
  36. package/dist-server/service/chat-session/session-anchor.js +3 -1
  37. package/dist-server/service/chat-session/session-anchor.js.map +1 -1
  38. package/dist-server/service/chat-session/station-session-guard.d.ts +12 -0
  39. package/dist-server/service/chat-session/station-session-guard.js +20 -0
  40. package/dist-server/service/chat-session/station-session-guard.js.map +1 -0
  41. package/dist-server/tsconfig.tsbuildinfo +1 -1
  42. package/docs/session-controller.md +52 -0
  43. package/package.json +2 -2
  44. package/server/service/assistant-chat-resolver.ts +22 -0
  45. package/server/service/chat-session/chat-session.ts +1 -1
  46. package/server/service/chat-session/session-anchor.ts +3 -1
  47. package/server/service/chat-session/station-session-guard.ts +17 -0
  48. package/server/service/session-anchor.test.ts +11 -0
  49. package/server/service/station-session-guard.test.ts +24 -0
  50. package/test/assistant-request-context.test.ts +30 -0
  51. package/test/assistant-session-controller.test.ts +88 -0
  52. package/test/assistant-session-toolbar.test.ts +29 -0
  53. package/translations/en.json +12 -1
  54. package/translations/ko.json +12 -1
  55. package/dist-client/components/board-ai-chat.js.map +0 -1
  56. package/dist-client/components/board-ai-chat.test.js.map +0 -1
  57. /package/client/components/{board-ai-chat.test.ts → assistant-chat.test.ts} +0 -0
  58. /package/dist-client/components/{board-ai-chat.test.d.ts → assistant-chat.test.d.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * <ox-board-ai-chat> — AI 주도 보드 모델링 채팅 컴포넌트 (Lit).
2
+ * <ox-assistant-chat> — AI 도우미 채팅 컴포넌트 (Lit).
3
3
  *
4
4
  * 입력:
5
5
  * - sessionId: 영속 ChatSession 식별자 (없으면 ad-hoc 모드, 메시지 영속 안 됨)
@@ -14,6 +14,8 @@
14
14
  * 모드 전환은 컨테이너 (워크스페이스) 의 책임. 이 컴포넌트는 자체로 풀 채팅 UX.
15
15
  */
16
16
  import '@material/web/icon/icon.js'
17
+ import './assistant-session-toolbar.js'
18
+ import { assistantAutoAskKey, assistantModelFingerprint, type AssistantRequestContext } from '../utils/assistant-request-context.js'
17
19
 
18
20
  import { LitElement, css, html, nothing } from 'lit'
19
21
  import { customElement, property, state } from 'lit/decorators.js'
@@ -104,6 +106,29 @@ interface ChatLine {
104
106
  * 있으면 그 대상은 만들어 낸 것일 수 있다는 주의를 답 아래에 표시한다(답 자체는 그대로 보여준다).
105
107
  */
106
108
  groundingWarnings?: string[]
109
+ attachments?: Array<{
110
+ name: string
111
+ size: number
112
+ type: string
113
+ url?: string
114
+ base64?: string
115
+ }>
116
+ }
117
+
118
+ export interface ChatAttachment {
119
+ id: string
120
+ file: File
121
+ name: string
122
+ size: number
123
+ type: string
124
+ url: string
125
+ base64?: string
126
+ }
127
+
128
+ function formatFileSize(bytes: number): string {
129
+ if (!bytes || bytes < 1024) return `${bytes || 0} B`
130
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
131
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
107
132
  }
108
133
 
109
134
  const BOARD_AI_CHAT_MUTATION = gql`
@@ -220,11 +245,13 @@ function initials(name: string): string {
220
245
  return /[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(0, 1)
221
246
  }
222
247
 
223
- @customElement('ox-board-ai-chat')
224
- export class OxBoardAIChat extends LitElement {
248
+ @customElement('ox-assistant-chat')
249
+ export class OxAssistantChat extends LitElement {
225
250
  /** 영속 세션 id. 없으면 ad-hoc (메시지 영속 안 됨). */
226
251
  @property({ type: String, attribute: 'session-id' })
227
252
  sessionId?: string
253
+ @property({ attribute: false }) prepareSession?: (adopt: (id: string) => void) => Promise<string | undefined>
254
+ private adoptingSession = false
228
255
 
229
256
  /**
230
257
  * 보드의 모든 ChatSession 리스트 — 다중 세션 활성화 시 탭 UI 로 표시.
@@ -385,6 +412,8 @@ export class OxBoardAIChat extends LitElement {
385
412
  */
386
413
  @property({ attribute: false })
387
414
  autoAsk?: string
415
+ @property() autoAskRequestId?: string
416
+ @property({ attribute: false }) contextProvider?: () => AssistantRequestContext
388
417
 
389
418
  /**
390
419
  * 빈 화면의 제목·부제 — **호스트가 자기 말로 준다.**
@@ -425,6 +454,102 @@ export class OxBoardAIChat extends LitElement {
425
454
  @state()
426
455
  private revertedPatchIds = new Set<string>()
427
456
 
457
+ /** 첨부된 파일/이미지 목록 */
458
+ @state()
459
+ private attachments: ChatAttachment[] = []
460
+
461
+ /** 드래그 오버 상태 (시각 피드백용) */
462
+ @state()
463
+ private dragOver = false
464
+
465
+ private async onAttachFiles(files: FileList | File[] | null | undefined) {
466
+ if (!files || files.length === 0) return
467
+ const list = Array.from(files)
468
+ const newAttachments: ChatAttachment[] = []
469
+
470
+ for (const file of list) {
471
+ const id = 'att-' + Math.random().toString(36).substring(2, 9)
472
+ const url = URL.createObjectURL(file)
473
+ let base64: string | undefined
474
+ try {
475
+ base64 = await this.readFileAsBase64(file)
476
+ } catch (err) {
477
+ console.warn('Failed to read file as base64', err)
478
+ }
479
+ newAttachments.push({
480
+ id,
481
+ file,
482
+ name: file.name,
483
+ size: file.size,
484
+ type: file.type || 'application/octet-stream',
485
+ url,
486
+ base64
487
+ })
488
+ }
489
+
490
+ this.attachments = [...this.attachments, ...newAttachments]
491
+ }
492
+
493
+ private readFileAsBase64(file: File): Promise<string> {
494
+ return new Promise((resolve, reject) => {
495
+ const reader = new FileReader()
496
+ reader.onload = () => resolve(reader.result as string)
497
+ reader.onerror = reject
498
+ reader.readAsDataURL(file)
499
+ })
500
+ }
501
+
502
+ private removeAttachment(id: string) {
503
+ const target = this.attachments.find(a => a.id === id)
504
+ if (target?.url) {
505
+ URL.revokeObjectURL(target.url)
506
+ }
507
+ this.attachments = this.attachments.filter(a => a.id !== id)
508
+ }
509
+
510
+ private onComposerPaste(e: ClipboardEvent) {
511
+ const items = e.clipboardData?.items
512
+ if (!items || items.length === 0) return
513
+
514
+ const files: File[] = []
515
+ for (let i = 0; i < items.length; i++) {
516
+ const item = items[i]
517
+ if (item.kind === 'file') {
518
+ const file = item.getAsFile()
519
+ if (file) files.push(file)
520
+ }
521
+ }
522
+
523
+ if (files.length > 0) {
524
+ this.onAttachFiles(files)
525
+ }
526
+ }
527
+
528
+ private onComposerDragOver(e: DragEvent) {
529
+ e.preventDefault()
530
+ e.stopPropagation()
531
+ if (!this.dragOver) this.dragOver = true
532
+ }
533
+
534
+ private onComposerDragLeave(e: DragEvent) {
535
+ e.preventDefault()
536
+ e.stopPropagation()
537
+ const currentTarget = e.currentTarget as HTMLElement
538
+ const relatedTarget = e.relatedTarget as Node | null
539
+ if (!currentTarget.contains(relatedTarget)) {
540
+ this.dragOver = false
541
+ }
542
+ }
543
+
544
+ private onComposerDrop(e: DragEvent) {
545
+ e.preventDefault()
546
+ e.stopPropagation()
547
+ this.dragOver = false
548
+ if (e.dataTransfer?.files?.length) {
549
+ this.onAttachFiles(e.dataTransfer.files)
550
+ }
551
+ }
552
+
428
553
  /** mini action — 인라인 예시 토글 */
429
554
  @state()
430
555
  private examplesOpen = false
@@ -1326,6 +1451,63 @@ export class OxBoardAIChat extends LitElement {
1326
1451
  .proposal.sent {
1327
1452
  border-left-color: var(--md-sys-color-outline, #94a3b8);
1328
1453
  }
1454
+ .proposal .staged-badge {
1455
+ display: inline-flex;
1456
+ align-items: center;
1457
+ gap: 3px;
1458
+ padding: 3px 8px;
1459
+ border-radius: 999px;
1460
+ background: rgba(15, 118, 110, 0.1);
1461
+ color: var(--md-sys-color-primary, #0f766e);
1462
+ font-size: 11px;
1463
+ font-weight: 600;
1464
+ flex-shrink: 0;
1465
+ }
1466
+ .proposal .staged-badge md-icon {
1467
+ --md-icon-size: 13px;
1468
+ color: inherit;
1469
+ }
1470
+
1471
+ .proposal-choices {
1472
+ display: flex;
1473
+ flex-wrap: wrap;
1474
+ gap: 6px;
1475
+ margin-top: 6px;
1476
+ padding: 6px 8px;
1477
+ background: var(--md-sys-color-surface-container-lowest, #ffffff);
1478
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1479
+ border-radius: 8px;
1480
+ }
1481
+ .proposal-choices .choice-chip {
1482
+ display: inline-flex;
1483
+ align-items: center;
1484
+ gap: 4px;
1485
+ padding: 4px 9px;
1486
+ border-radius: 999px;
1487
+ border: 1px solid var(--md-sys-color-outline-variant, #cbd5e1);
1488
+ background: var(--md-sys-color-surface-container-low, #f8fafc);
1489
+ color: var(--md-sys-color-on-surface, #1e293b);
1490
+ font-family: inherit;
1491
+ font-size: 11px;
1492
+ font-weight: 550;
1493
+ cursor: pointer;
1494
+ transition: all 0.14s ease-in-out;
1495
+ }
1496
+ .proposal-choices .choice-chip:hover {
1497
+ background: var(--md-sys-color-surface-container-high, #e2e8f0);
1498
+ border-color: var(--md-sys-color-primary, #0f766e);
1499
+ color: var(--md-sys-color-primary, #0f766e);
1500
+ }
1501
+ .proposal-choices .choice-chip.primary {
1502
+ background: var(--md-sys-color-primary-container, #ccfbf1);
1503
+ color: var(--md-sys-color-on-primary-container, #0f766e);
1504
+ border-color: var(--md-sys-color-primary, #0f766e);
1505
+ font-weight: 650;
1506
+ }
1507
+ .proposal-choices .choice-chip md-icon {
1508
+ --md-icon-size: 13px;
1509
+ color: inherit;
1510
+ }
1329
1511
 
1330
1512
  /* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
1331
1513
  .grounding-warning {
@@ -1628,10 +1810,156 @@ export class OxBoardAIChat extends LitElement {
1628
1810
 
1629
1811
  /* ── Composer (actions + input 통합 영역) ──────────── */
1630
1812
  .composer {
1813
+ position: relative;
1631
1814
  border-top: 1px solid var(--md-sys-color-outline-variant, #f1f5f9);
1632
1815
  background: var(--md-sys-color-surface, #ffffff);
1633
1816
  display: flex;
1634
1817
  flex-direction: column;
1818
+ transition: background 0.15s, border-color 0.15s;
1819
+ }
1820
+ .composer.drag-over {
1821
+ background: var(--md-sys-color-surface-container, #f1f5f9);
1822
+ border-top-color: var(--md-sys-color-primary, #0284c7);
1823
+ }
1824
+ .drag-drop-overlay {
1825
+ position: absolute;
1826
+ top: 0;
1827
+ left: 0;
1828
+ right: 0;
1829
+ bottom: 0;
1830
+ background: rgba(255, 255, 255, 0.94);
1831
+ backdrop-filter: blur(2px);
1832
+ z-index: 10;
1833
+ display: flex;
1834
+ flex-direction: column;
1835
+ align-items: center;
1836
+ justify-content: center;
1837
+ gap: 6px;
1838
+ border: 2px dashed var(--md-sys-color-primary, #0284c7);
1839
+ border-radius: 4px;
1840
+ color: var(--md-sys-color-primary, #0284c7);
1841
+ font-size: 13px;
1842
+ font-weight: 500;
1843
+ pointer-events: none;
1844
+ }
1845
+ .drag-drop-overlay md-icon {
1846
+ --md-icon-size: 28px;
1847
+ }
1848
+ .attachment-strip {
1849
+ display: flex;
1850
+ flex-wrap: wrap;
1851
+ gap: 6px;
1852
+ padding: 6px 14px 2px;
1853
+ max-height: 120px;
1854
+ overflow-y: auto;
1855
+ }
1856
+ .attachment-chip {
1857
+ display: flex;
1858
+ align-items: center;
1859
+ gap: 6px;
1860
+ background: var(--md-sys-color-surface-container, #f1f5f9);
1861
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1862
+ border-radius: 8px;
1863
+ padding: 3px 6px 3px 4px;
1864
+ max-width: 220px;
1865
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
1866
+ }
1867
+ .attachment-thumb {
1868
+ width: 28px;
1869
+ height: 28px;
1870
+ object-fit: cover;
1871
+ border-radius: 4px;
1872
+ flex: none;
1873
+ }
1874
+ .attachment-icon {
1875
+ --md-icon-size: 20px;
1876
+ color: var(--md-sys-color-primary, #0284c7);
1877
+ flex: none;
1878
+ }
1879
+ .attachment-meta {
1880
+ display: flex;
1881
+ flex-direction: column;
1882
+ min-width: 0;
1883
+ flex: 1;
1884
+ }
1885
+ .attachment-name {
1886
+ font-size: 11px;
1887
+ font-weight: 500;
1888
+ color: var(--md-sys-color-on-surface, #0f172a);
1889
+ overflow: hidden;
1890
+ text-overflow: ellipsis;
1891
+ white-space: nowrap;
1892
+ }
1893
+ .attachment-size {
1894
+ font-size: 9px;
1895
+ color: var(--md-sys-color-outline, #64748b);
1896
+ }
1897
+ .attachment-remove {
1898
+ all: unset;
1899
+ display: flex;
1900
+ align-items: center;
1901
+ justify-content: center;
1902
+ width: 18px;
1903
+ height: 18px;
1904
+ border-radius: 50%;
1905
+ cursor: pointer;
1906
+ color: var(--md-sys-color-outline, #64748b);
1907
+ transition: background 0.15s, color 0.15s;
1908
+ flex: none;
1909
+ }
1910
+ .attachment-remove:hover {
1911
+ background: var(--md-sys-color-surface-container-highest, #cbd5e1);
1912
+ color: var(--md-sys-color-error, #ef4444);
1913
+ }
1914
+ .attachment-remove md-icon {
1915
+ --md-icon-size: 14px;
1916
+ }
1917
+ .msg-attachments {
1918
+ display: flex;
1919
+ flex-wrap: wrap;
1920
+ gap: 8px;
1921
+ margin-top: 8px;
1922
+ }
1923
+ .msg-attachment-img-wrap {
1924
+ border-radius: 8px;
1925
+ overflow: hidden;
1926
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1927
+ background: var(--md-sys-color-surface-container-lowest, #ffffff);
1928
+ max-width: 240px;
1929
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
1930
+ display: flex;
1931
+ flex-direction: column;
1932
+ }
1933
+ .msg-attachment-img-wrap img {
1934
+ max-width: 100%;
1935
+ max-height: 160px;
1936
+ object-fit: cover;
1937
+ display: block;
1938
+ }
1939
+ .msg-attachment-label {
1940
+ padding: 4px 8px;
1941
+ font-size: 10px;
1942
+ color: var(--md-sys-color-on-surface-variant, #64748b);
1943
+ background: var(--md-sys-color-surface-container, #f8fafc);
1944
+ overflow: hidden;
1945
+ text-overflow: ellipsis;
1946
+ white-space: nowrap;
1947
+ border-top: 1px solid var(--md-sys-color-outline-variant, #f1f5f9);
1948
+ }
1949
+ .msg-attachment-file-chip {
1950
+ display: flex;
1951
+ align-items: center;
1952
+ gap: 6px;
1953
+ padding: 6px 10px;
1954
+ border-radius: 8px;
1955
+ background: var(--md-sys-color-surface-container, #f8fafc);
1956
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1957
+ font-size: 11px;
1958
+ color: var(--md-sys-color-on-surface, #0f172a);
1959
+ }
1960
+ .msg-attachment-file-chip md-icon {
1961
+ --md-icon-size: 18px;
1962
+ color: var(--md-sys-color-primary, #0284c7);
1635
1963
  }
1636
1964
 
1637
1965
  /* ── Mini action row (메시지 시작 후) ──────────────── */
@@ -1842,13 +2170,17 @@ export class OxBoardAIChat extends LitElement {
1842
2170
  * sessionId 가 붙기 전에 보내면 대화에 남지 않으므로 세션이 준비될 때까지 기다린다.
1843
2171
  * 같은 문장을 두 번 보내지 않도록 보낸 값을 기억한다 — 재렌더마다 updated 가 다시 돌기 때문이다.
1844
2172
  */
1845
- if (this.autoAsk && this.autoAsk !== this._autoAsked && this.sessionId && !this.busy) {
1846
- this._autoAsked = this.autoAsk
2173
+ if (this.autoAsk && assistantAutoAskKey(this.autoAsk, this.autoAskRequestId) !== this._autoAsked && (this.sessionId || this.prepareSession) && !this.busy) {
2174
+ this._autoAsked = assistantAutoAskKey(this.autoAsk, this.autoAskRequestId)
1847
2175
  this.input = this.autoAsk
1848
2176
  this.send()
1849
2177
  }
1850
2178
 
1851
- if (changed.has('sessionId')) {
2179
+ if (changed.has('sessionId') && this.adoptingSession) {
2180
+ this.adoptingSession = false
2181
+ this._startPresence()
2182
+ this._startMessageSubscription()
2183
+ } else if (changed.has('sessionId')) {
1852
2184
  // sessionId 가 바뀌면 (set / unset / 다른 보드 전환) 이전 세션의 모든 클라이언트
1853
2185
  // 컨텍스트 정리 — lines / pickedMentions / errorMessage / input 모두 새지 않도록.
1854
2186
  // 보드 단위 격리 (ChatSession 1:1 boardId) 가 깨지는 회귀 방지.
@@ -2055,6 +2387,27 @@ export class OxBoardAIChat extends LitElement {
2055
2387
  >${content}</span
2056
2388
  >`
2057
2389
  : content}
2390
+ ${line.attachments && line.attachments.length > 0
2391
+ ? html`
2392
+ <div class="msg-attachments">
2393
+ ${line.attachments.map(att =>
2394
+ att.type?.startsWith('image/') || att.url?.startsWith('data:image/') || att.base64
2395
+ ? html`
2396
+ <div class="msg-attachment-img-wrap" title=${att.name}>
2397
+ <img src=${att.url || att.base64} alt=${att.name} />
2398
+ <span class="msg-attachment-label">${att.name}</span>
2399
+ </div>
2400
+ `
2401
+ : html`
2402
+ <div class="msg-attachment-file-chip" title=${att.name}>
2403
+ <md-icon>description</md-icon>
2404
+ <span>${att.name}</span>
2405
+ </div>
2406
+ `
2407
+ )}
2408
+ </div>
2409
+ `
2410
+ : nothing}
2058
2411
  ${line.patchId
2059
2412
  ? html`
2060
2413
  <div class="summary ${this.revertedPatchIds.has(line.patchId) ? 'reverted' : ''}">
@@ -2105,11 +2458,47 @@ export class OxBoardAIChat extends LitElement {
2105
2458
  `
2106
2459
  : nothing}
2107
2460
  ${this.renderFoldNotice()}
2108
- <div class="composer ${hasMessages ? '' : 'no-actions'}">
2109
- ${hasMessages
2461
+ <div
2462
+ class="composer ${this.dragOver ? 'drag-over' : ''}"
2463
+ @dragover=${(e: DragEvent) => this.onComposerDragOver(e)}
2464
+ @dragleave=${(e: DragEvent) => this.onComposerDragLeave(e)}
2465
+ @drop=${(e: DragEvent) => this.onComposerDrop(e)}
2466
+ @paste=${(e: ClipboardEvent) => this.onComposerPaste(e)}>
2467
+ ${this.dragOver
2110
2468
  ? html`
2111
- <div class="actions-row">
2469
+ <div class="drag-drop-overlay">
2470
+ <md-icon>cloud_upload</md-icon>
2471
+ <span>${i18next.t('ai-assistant.text.drop-files-here', { defaultValue: '여기에 이미지나 파일을 놓으세요' })}</span>
2472
+ </div>
2473
+ `
2474
+ : nothing}
2475
+ <div class="actions-row">
2476
+ <input
2477
+ id="chat-file-input"
2478
+ type="file"
2479
+ multiple
2480
+ accept="image/*,.json,.txt,.csv"
2481
+ style="display: none"
2482
+ @change=${(e: Event) => {
2483
+ const input = e.target as HTMLInputElement
2484
+ if (input.files?.length) {
2485
+ this.onAttachFiles(input.files)
2486
+ input.value = ''
2487
+ }
2488
+ }}
2489
+ />
2490
+ <button
2491
+ type="button"
2492
+ class="action"
2493
+ @click=${() => this.renderRoot.querySelector<HTMLInputElement>('#chat-file-input')?.click()}
2494
+ title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: '이미지 또는 파일 첨부 (클립보드 붙여넣기·드래그앤드롭 지원)' })}>
2495
+ <md-icon>attach_file</md-icon>
2496
+ ${i18next.t('ai-assistant.button.attach', { defaultValue: '첨부' })}
2497
+ </button>
2498
+ ${hasMessages
2499
+ ? html`
2112
2500
  <button
2501
+ type="button"
2113
2502
  class="action"
2114
2503
  @click=${() => (this.examplesOpen = !this.examplesOpen)}
2115
2504
  title=${i18next.t('ai-assistant.text.show-examples-tooltip')}>
@@ -2118,8 +2507,34 @@ export class OxBoardAIChat extends LitElement {
2118
2507
  ? i18next.t('ai-assistant.button.close')
2119
2508
  : i18next.t('ai-assistant.button.examples')}
2120
2509
  </button>
2510
+ `
2511
+ : nothing}
2512
+ </div>
2513
+ ${this.examplesOpen ? this.renderInlineExamples() : nothing}
2514
+ ${this.attachments.length > 0
2515
+ ? html`
2516
+ <div class="attachment-strip">
2517
+ ${this.attachments.map(
2518
+ att => html`
2519
+ <div class="attachment-chip" title=${att.name}>
2520
+ ${att.type.startsWith('image/')
2521
+ ? html`<img class="attachment-thumb" src=${att.url} alt=${att.name} />`
2522
+ : html`<md-icon class="attachment-icon">description</md-icon>`}
2523
+ <div class="attachment-meta">
2524
+ <span class="attachment-name">${att.name}</span>
2525
+ <span class="attachment-size">${formatFileSize(att.size)}</span>
2526
+ </div>
2527
+ <button
2528
+ type="button"
2529
+ class="attachment-remove"
2530
+ title=${i18next.t('ai-assistant.button.remove-attachment', { defaultValue: '첨부 삭제' })}
2531
+ @click=${() => this.removeAttachment(att.id)}>
2532
+ <md-icon>close</md-icon>
2533
+ </button>
2534
+ </div>
2535
+ `
2536
+ )}
2121
2537
  </div>
2122
- ${this.examplesOpen ? this.renderInlineExamples() : nothing}
2123
2538
  `
2124
2539
  : nothing}
2125
2540
  <div class="input-row">
@@ -2157,7 +2572,7 @@ export class OxBoardAIChat extends LitElement {
2157
2572
  : nothing}
2158
2573
  </div>
2159
2574
  <button
2160
- ?disabled=${this.busy || !this.input.trim()}
2575
+ ?disabled=${this.busy || (!this.input.trim() && this.attachments.length === 0)}
2161
2576
  @click=${this.send}
2162
2577
  title=${i18next.t('ai-assistant.text.send-tooltip')}>
2163
2578
  ${this.busy
@@ -2482,7 +2897,7 @@ export class OxBoardAIChat extends LitElement {
2482
2897
  */
2483
2898
  private get exampleGroups(): Array<{ label: string; items: string[] }> {
2484
2899
  if (this.examples?.length) return this.examples
2485
- return OxBoardAIChat.EXAMPLE_GROUPS.map(group => ({
2900
+ return OxAssistantChat.EXAMPLE_GROUPS.map(group => ({
2486
2901
  label: i18next.t(group.labelKey),
2487
2902
  items: group.itemKeys.map(key => i18next.t(key))
2488
2903
  }))
@@ -2529,38 +2944,11 @@ export class OxBoardAIChat extends LitElement {
2529
2944
  private renderSessionTabs() {
2530
2945
  if (!this.sessions || this.sessions.length === 0) return nothing
2531
2946
  return html`
2532
- <div class="session-tabs" role="tablist">
2533
- ${this.sessions.map((s, i) => {
2534
- const isActive = s.id === this.sessionId
2535
- const fallback = i18next.t('ai-assistant.text.session-tab-label', {
2536
- n: i + 1,
2537
- defaultValue: `세션 ${i + 1}`
2538
- })
2539
- const label = s.name?.trim() || fallback
2540
- const tooltip = s.createdAt
2541
- ? `${label} · ${new Date(s.createdAt).toLocaleString()}`
2542
- : label
2543
- return html`
2544
- <button
2545
- class="session-tab ${isActive ? 'active' : ''}"
2546
- role="tab"
2547
- aria-selected=${isActive ? 'true' : 'false'}
2548
- title=${tooltip}
2549
- @click=${() => this._onSessionTabClick(s.id)}>
2550
- ${label}
2551
- </button>
2552
- `
2553
- })}
2554
- <button
2555
- class="session-tab-new"
2556
- title=${i18next.t('ai-assistant.button.new-chat', {
2557
- defaultValue: '새 대화'
2558
- })}
2559
- @click=${this._onSessionCreateClick}>
2560
- <md-icon>add</md-icon>
2561
- <span>${i18next.t('ai-assistant.button.new-chat', { defaultValue: '새 대화' })}</span>
2562
- </button>
2563
- </div>
2947
+ <ox-assistant-session-toolbar style="padding:8px 12px" .sessions=${this.sessions}
2948
+ .sessionId=${this.sessionId || ''} .disabled=${this.busy}
2949
+ @assistant-session-select=${(event: CustomEvent) => { event.stopPropagation(); this._onSessionTabClick(event.detail.sessionId) }}
2950
+ @assistant-session-reset=${(event: Event) => { event.stopPropagation(); this._onSessionCreateClick() }}
2951
+ ></ox-assistant-session-toolbar>
2564
2952
  `
2565
2953
  }
2566
2954
 
@@ -2598,6 +2986,7 @@ export class OxBoardAIChat extends LitElement {
2598
2986
  }
2599
2987
 
2600
2988
  private _onSessionCreateClick = () => {
2989
+ if (this.busy || this.lines.length === 0) return
2601
2990
  this.dispatchEvent(
2602
2991
  new CustomEvent('session-create', {
2603
2992
  bubbles: true,
@@ -2792,7 +3181,7 @@ export class OxBoardAIChat extends LitElement {
2792
3181
  */
2793
3182
  private startNewFromNotice = () => {
2794
3183
  this.foldNoticeDismissed = true
2795
- this.dispatchEvent(new CustomEvent('board-ai-start-new-session', { bubbles: true, composed: true }))
3184
+ this.dispatchEvent(new CustomEvent('assistant-start-new-session', { bubbles: true, composed: true }))
2796
3185
  }
2797
3186
 
2798
3187
  /**
@@ -2810,27 +3199,90 @@ export class OxBoardAIChat extends LitElement {
2810
3199
  ${items.map(p => {
2811
3200
  const key = this.proposalKey(p)
2812
3201
  const sent = this.sentProposals.has(key)
3202
+ const rawChoices: any[] = Array.isArray(p.choices) ? p.choices : (p.source ? [
3203
+ { id: 'save', action: 'save', label: i18next.t('label.save-as-draft', { defaultValue: '초안 저장' }), icon: 'save', primary: true },
3204
+ { id: 'repropose', action: 'prompt', label: '다른 콘셉트 재제안', icon: 'refresh', prompt: '현재 제안과 완전히 다른 스타일과 콘셉트로 새로 제안해줘' },
3205
+ { id: 'slim', action: 'prompt', label: '비율 슬림 조정', icon: 'aspect_ratio', prompt: '전체적인 가로세로 비율을 좀 더 슬림하고 날렵하게 조정해줘' },
3206
+ { id: 'palette', action: 'prompt', label: '색상 테마 변경', icon: 'palette', prompt: '다른 팔레트 색상 테마로 부품 색상을 변경해줘' },
3207
+ { id: 'detail', action: 'prompt', label: '디테일 보강', icon: 'extension', prompt: '주요 디테일과 센서 부품을 좀 더 풍부하게 보강해줘' }
3208
+ ] : [])
3209
+
2813
3210
  return html`
2814
3211
  <div class="proposal ${sent ? 'sent' : ''}">
2815
- <md-icon>bolt</md-icon>
3212
+ <md-icon>${p.icon || (p.source ? 'view_in_ar' : 'bolt')}</md-icon>
2816
3213
  <span class="pbody">
2817
3214
  <span class="plabel">${p.label || p.command}</span>
2818
3215
  ${p.reason ? html`<span class="preason">${p.reason}</span>` : nothing}
2819
3216
  </span>
2820
- <button
2821
- class="run"
2822
- ?disabled=${sent}
2823
- @click=${() => this.executeProposal(p, key)}>
2824
- ${sent ? i18next.t('ai-assistant.text.proposal-sent', { defaultValue: '실행 요청됨' })
2825
- : i18next.t('ai-assistant.button.run-proposal', { defaultValue: '실행' })}
2826
- </button>
3217
+ ${!rawChoices.length ? html`
3218
+ <button
3219
+ class="run"
3220
+ ?disabled=${sent}
3221
+ @click=${() => this.executeProposal(p, key)}>
3222
+ ${sent ? i18next.t('ai-assistant.text.proposal-sent', { defaultValue: '실행 요청됨' })
3223
+ : i18next.t('ai-assistant.button.run-proposal', { defaultValue: '실행' })}
3224
+ </button>
3225
+ ` : html`
3226
+ <span class="staged-badge" title="3D 뷰어에 모델이 표시되었습니다">
3227
+ <md-icon>check_circle</md-icon>
3228
+ <span>뷰어 반영됨</span>
3229
+ </span>
3230
+ `}
2827
3231
  </div>
3232
+ ${rawChoices.length ? html`
3233
+ <div class="proposal-choices" role="group" aria-label="대화 선택지">
3234
+ ${rawChoices.map((c: any) => html`
3235
+ <button
3236
+ class="choice-chip ${c.primary ? 'primary' : ''}"
3237
+ title=${c.prompt || c.label}
3238
+ @click=${() => this.onChoiceClick(c, p, key)}>
3239
+ ${c.icon ? html`<md-icon>${c.icon}</md-icon>` : nothing}
3240
+ <span>${c.label}</span>
3241
+ </button>
3242
+ `)}
3243
+ </div>
3244
+ ` : nothing}
2828
3245
  `
2829
3246
  })}
2830
3247
  </div>
2831
3248
  `
2832
3249
  }
2833
3250
 
3251
+ private onChoiceClick(choice: any, proposal: any, key: string) {
3252
+ if (choice.action === 'save') {
3253
+ this.dispatchEvent(
3254
+ new CustomEvent('assistant-choice', {
3255
+ detail: { action: 'save', choice, proposal, sessionId: this.sessionId },
3256
+ bubbles: true,
3257
+ composed: true
3258
+ })
3259
+ )
3260
+ this.executeProposal(proposal, key)
3261
+ return
3262
+ }
3263
+
3264
+ if (choice.action === 'prompt' && choice.prompt) {
3265
+ this.input = choice.prompt
3266
+ this.dispatchEvent(
3267
+ new CustomEvent('assistant-choice', {
3268
+ detail: { action: 'prompt', choice, proposal, sessionId: this.sessionId },
3269
+ bubbles: true,
3270
+ composed: true
3271
+ })
3272
+ )
3273
+ void this.send()
3274
+ return
3275
+ }
3276
+
3277
+ this.dispatchEvent(
3278
+ new CustomEvent('assistant-choice', {
3279
+ detail: { action: choice.action || 'custom', choice, proposal, sessionId: this.sessionId },
3280
+ bubbles: true,
3281
+ composed: true
3282
+ })
3283
+ )
3284
+ }
3285
+
2834
3286
  /**
2835
3287
  * 제안 식별 키 — 같은 조치를 두 번 실행하지 않도록 **효과**(명령·대상·인자)로만 만든다.
2836
3288
  *
@@ -2861,7 +3313,7 @@ export class OxBoardAIChat extends LitElement {
2861
3313
  private executeProposal(p: any, key: string) {
2862
3314
  this.sentProposals = new Set(this.sentProposals).add(key)
2863
3315
  this.dispatchEvent(
2864
- new CustomEvent('board-ai-proposal-execute', {
3316
+ new CustomEvent('assistant-proposal-execute', {
2865
3317
  detail: { proposal: p, sessionId: this.sessionId },
2866
3318
  bubbles: true,
2867
3319
  composed: true
@@ -3140,19 +3592,53 @@ export class OxBoardAIChat extends LitElement {
3140
3592
 
3141
3593
  private async send() {
3142
3594
  const text = this.input.trim()
3143
- if (!text || this.busy) return
3595
+ const attachmentsToSend = [...this.attachments]
3596
+ if ((!text && attachmentsToSend.length === 0) || this.busy) return
3597
+ this.attachments = []
3598
+
3599
+ const promptText = text || (attachmentsToSend.length > 0
3600
+ ? (attachmentsToSend[0].type.startsWith('image/')
3601
+ ? '첨부된 이미지를 참고하여 제안해 주세요.'
3602
+ : '첨부된 파일을 확인해 주세요.')
3603
+ : '')
3604
+ if (!this.sessionId && this.prepareSession) {
3605
+ this.busy = true
3606
+ try {
3607
+ await this.prepareSession(id => { this.adoptingSession = true; this.sessionId = id })
3608
+ await this.updateComplete
3609
+ } catch (error) {
3610
+ this.errorMessage = error instanceof Error ? error.message : String(error)
3611
+ this.busy = false
3612
+ return
3613
+ }
3614
+ this.busy = false
3615
+ }
3616
+
3617
+ let requestContext: AssistantRequestContext
3618
+ let liveBoard: any
3619
+ let requestBase: any
3620
+ try {
3621
+ const provided = this.contextProvider?.() || {}
3622
+ requestContext = structuredClone({ systemPrompt: this.systemPrompt, hostContext: this.hostContext, ...provided })
3623
+ liveBoard = structuredClone(this.boardProvider ? this.boardProvider() : this.currentBoard)
3624
+ requestBase = { version: 1, sessionId: this.sessionId, hostContext: requestContext.hostContext,
3625
+ modelFingerprint: assistantModelFingerprint(liveBoard) }
3626
+ } catch (error) {
3627
+ this.errorMessage = error instanceof Error ? error.message : String(error)
3628
+ return
3629
+ }
3144
3630
 
3145
3631
  // pickedMentions / pickedUserMentions 에서 현재 텍스트에 실제 #/@ token 으로
3146
3632
  // 등장하는 것만 추출 — 지운 토큰 제외. inline 마커로 content 에 주입.
3147
3633
  const mentions: Array<{ token: string; refid: number }> = []
3148
3634
  for (const [token, refid] of this.pickedMentions.entries()) {
3149
- if (text.includes(`#${token}`)) mentions.push({ token, refid })
3635
+ if (promptText.includes(`#${token}`)) mentions.push({ token, refid })
3150
3636
  }
3151
3637
  const userMentions: Array<{ token: string; userId: string }> = []
3152
3638
  for (const [token, userId] of this.pickedUserMentions.entries()) {
3153
- if (text.includes(`@${token}`)) userMentions.push({ token, userId })
3639
+ if (promptText.includes(`@${token}`)) userMentions.push({ token, userId })
3154
3640
  }
3155
- let enrichedContent = injectMentionRefids(text, mentions)
3641
+ let enrichedContent = injectMentionRefids(promptText, mentions)
3156
3642
  enrichedContent = injectMentionUserIds(enrichedContent, userMentions)
3157
3643
 
3158
3644
  // optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
@@ -3161,7 +3647,19 @@ export class OxBoardAIChat extends LitElement {
3161
3647
 
3162
3648
  this.lines = [
3163
3649
  ...this.lines,
3164
- { role: 'user', content: enrichedContent, _localId: userLocalId, senderEmail: this._myEmail }
3650
+ {
3651
+ role: 'user',
3652
+ content: enrichedContent,
3653
+ attachments: attachmentsToSend.map(a => ({
3654
+ name: a.name,
3655
+ size: a.size,
3656
+ type: a.type,
3657
+ url: a.url,
3658
+ base64: a.base64
3659
+ })),
3660
+ _localId: userLocalId,
3661
+ senderEmail: this._myEmail
3662
+ }
3165
3663
  ]
3166
3664
  this.input = ''
3167
3665
  this.busy = true
@@ -3171,6 +3669,24 @@ export class OxBoardAIChat extends LitElement {
3171
3669
  this.lines = [...this.lines, { role: 'assistant', content: '', pending: true, _localId: asstLocalId }]
3172
3670
 
3173
3671
  try {
3672
+ if (attachmentsToSend.length > 0) {
3673
+ requestContext.hostContext = {
3674
+ ...requestContext.hostContext,
3675
+ attachments: attachmentsToSend.map(a => ({
3676
+ name: a.name,
3677
+ size: a.size,
3678
+ mediaType: a.type,
3679
+ data: a.base64
3680
+ })),
3681
+ image: attachmentsToSend.find(a => a.type.startsWith('image/'))
3682
+ ? {
3683
+ name: attachmentsToSend.find(a => a.type.startsWith('image/'))!.name,
3684
+ mediaType: attachmentsToSend.find(a => a.type.startsWith('image/'))!.type,
3685
+ data: attachmentsToSend.find(a => a.type.startsWith('image/'))!.base64
3686
+ }
3687
+ : undefined
3688
+ }
3689
+ }
3174
3690
  // LLM 으로 보낼 history — user/assistant 만 (system 은 백엔드가 자동 합류).
3175
3691
  // pending placeholder 와 system 은 제외 (index 비의존 — 방송 append 와 무관).
3176
3692
  const history = this.lines
@@ -3179,8 +3695,6 @@ export class OxBoardAIChat extends LitElement {
3179
3695
 
3180
3696
  // 라이브 보드 우선 — 호스트의 캔버스가 사용자 수작업 편집을 들고 있을 수 있음.
3181
3697
  // boardProvider 가 있으면 send 시점에 그것을 pull, 없으면 정적 currentBoard.
3182
- const liveBoard = this.boardProvider ? this.boardProvider() : this.currentBoard
3183
-
3184
3698
  // mentions 는 위에서 이미 계산해 user line 에 thread — 그대로 mutation 입력으로 재사용.
3185
3699
  const neutral = this.chatEndpoint === 'assistantChat'
3186
3700
  const result = await client.mutate({
@@ -3190,9 +3704,9 @@ export class OxBoardAIChat extends LitElement {
3190
3704
  ? buildAssistantChatInput({
3191
3705
  sessionId: this.sessionId,
3192
3706
  history,
3193
- systemPrompt: this.systemPrompt!,
3707
+ systemPrompt: requestContext.systemPrompt!,
3194
3708
  truncateAfterMessageId: this._truncateAfterId,
3195
- hostContext: this.hostContext,
3709
+ hostContext: requestContext.hostContext,
3196
3710
  toolCategories: this.toolCategories,
3197
3711
  requireGroundingTools: this.requireGroundingTools
3198
3712
  })
@@ -3202,7 +3716,7 @@ export class OxBoardAIChat extends LitElement {
3202
3716
  liveBoard,
3203
3717
  scopes: this.scopes,
3204
3718
  truncateAfterMessageId: this._truncateAfterId,
3205
- hostContext: this.hostContext,
3719
+ hostContext: requestContext.hostContext,
3206
3720
  toolCategories: this.toolCategories,
3207
3721
  boardTools: this.boardTools,
3208
3722
  requireGroundingTools: this.requireGroundingTools,
@@ -3240,12 +3754,28 @@ export class OxBoardAIChat extends LitElement {
3240
3754
  pending: false
3241
3755
  })
3242
3756
 
3757
+ // 제안 자동 프리뷰 전파 (3D 뷰어 자동 반영)
3758
+ if (Array.isArray(out.proposals) && out.proposals.length > 0) {
3759
+ for (const p of out.proposals) {
3760
+ if (p?.source || p?.autoStage) {
3761
+ this.dispatchEvent(
3762
+ new CustomEvent('assistant-proposal-preview', {
3763
+ detail: { proposal: p, sessionId: out.sessionId },
3764
+ bubbles: true,
3765
+ composed: true
3766
+ })
3767
+ )
3768
+ }
3769
+ }
3770
+ }
3771
+
3243
3772
  // 호스트로 patch 이벤트 전파
3244
3773
  if (out.patch) {
3245
3774
  this.dispatchEvent(
3246
3775
  new CustomEvent('board-edit-patch', {
3247
3776
  detail: {
3248
3777
  patch: out.patch,
3778
+ requestBase,
3249
3779
  summary: out.patch.summary,
3250
3780
  confidence: out.patch.confidence,
3251
3781
  patchId: out.patchId,
@@ -3260,7 +3790,7 @@ export class OxBoardAIChat extends LitElement {
3260
3790
  if (Array.isArray(out.actions) && out.actions.length > 0) {
3261
3791
  this.dispatchEvent(
3262
3792
  new CustomEvent('board-action-execute', {
3263
- detail: { actions: out.actions, sessionId: out.sessionId },
3793
+ detail: { actions: out.actions, sessionId: out.sessionId, requestBase },
3264
3794
  bubbles: true,
3265
3795
  composed: true
3266
3796
  })
@@ -3288,6 +3818,7 @@ export class OxBoardAIChat extends LitElement {
3288
3818
 
3289
3819
  declare global {
3290
3820
  interface HTMLElementTagNameMap {
3291
- 'ox-board-ai-chat': OxBoardAIChat
3821
+ 'ox-assistant-chat': OxAssistantChat
3292
3822
  }
3293
3823
  }
3824
+