@things-factory/ai-assistant 10.1.13 → 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 (27) hide show
  1. package/client/components/{board-ai-chat.ts → assistant-chat.ts} +547 -25
  2. package/client/components/chat-input-builder.ts +1 -1
  3. package/client/index.ts +2 -3
  4. package/dist-client/components/{board-ai-chat.d.ts → assistant-chat.d.ts} +24 -3
  5. package/dist-client/components/{board-ai-chat.js → assistant-chat.js} +557 -72
  6. package/dist-client/components/assistant-chat.js.map +1 -0
  7. package/dist-client/components/{board-ai-chat.test.js → assistant-chat.test.js} +1 -1
  8. package/dist-client/components/assistant-chat.test.js.map +1 -0
  9. package/dist-client/components/chat-input-builder.js +1 -1
  10. package/dist-client/components/chat-input-builder.js.map +1 -1
  11. package/dist-client/index.d.ts +2 -3
  12. package/dist-client/index.js +2 -3
  13. package/dist-client/index.js.map +1 -1
  14. package/dist-client/tsconfig.tsbuildinfo +1 -1
  15. package/dist-server/service/assistant-chat-resolver.js +12 -0
  16. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  17. package/dist-server/tsconfig.tsbuildinfo +1 -1
  18. package/package.json +2 -2
  19. package/server/service/assistant-chat-resolver.ts +13 -0
  20. package/test/assistant-request-context.test.ts +1 -1
  21. package/test/assistant-session-toolbar.test.ts +1 -1
  22. package/translations/en.json +6 -1
  23. package/translations/ko.json +6 -1
  24. package/dist-client/components/board-ai-chat.js.map +0 -1
  25. package/dist-client/components/board-ai-chat.test.js.map +0 -1
  26. /package/client/components/{board-ai-chat.test.ts → assistant-chat.test.ts} +0 -0
  27. /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 모드, 메시지 영속 안 됨)
@@ -106,6 +106,29 @@ interface ChatLine {
106
106
  * 있으면 그 대상은 만들어 낸 것일 수 있다는 주의를 답 아래에 표시한다(답 자체는 그대로 보여준다).
107
107
  */
108
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`
109
132
  }
110
133
 
111
134
  const BOARD_AI_CHAT_MUTATION = gql`
@@ -222,8 +245,8 @@ function initials(name: string): string {
222
245
  return /[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(0, 1)
223
246
  }
224
247
 
225
- @customElement('ox-board-ai-chat')
226
- export class OxBoardAIChat extends LitElement {
248
+ @customElement('ox-assistant-chat')
249
+ export class OxAssistantChat extends LitElement {
227
250
  /** 영속 세션 id. 없으면 ad-hoc (메시지 영속 안 됨). */
228
251
  @property({ type: String, attribute: 'session-id' })
229
252
  sessionId?: string
@@ -431,6 +454,102 @@ export class OxBoardAIChat extends LitElement {
431
454
  @state()
432
455
  private revertedPatchIds = new Set<string>()
433
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
+
434
553
  /** mini action — 인라인 예시 토글 */
435
554
  @state()
436
555
  private examplesOpen = false
@@ -1332,6 +1451,63 @@ export class OxBoardAIChat extends LitElement {
1332
1451
  .proposal.sent {
1333
1452
  border-left-color: var(--md-sys-color-outline, #94a3b8);
1334
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
+ }
1335
1511
 
1336
1512
  /* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
1337
1513
  .grounding-warning {
@@ -1634,10 +1810,156 @@ export class OxBoardAIChat extends LitElement {
1634
1810
 
1635
1811
  /* ── Composer (actions + input 통합 영역) ──────────── */
1636
1812
  .composer {
1813
+ position: relative;
1637
1814
  border-top: 1px solid var(--md-sys-color-outline-variant, #f1f5f9);
1638
1815
  background: var(--md-sys-color-surface, #ffffff);
1639
1816
  display: flex;
1640
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);
1641
1963
  }
1642
1964
 
1643
1965
  /* ── Mini action row (메시지 시작 후) ──────────────── */
@@ -2065,6 +2387,27 @@ export class OxBoardAIChat extends LitElement {
2065
2387
  >${content}</span
2066
2388
  >`
2067
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}
2068
2411
  ${line.patchId
2069
2412
  ? html`
2070
2413
  <div class="summary ${this.revertedPatchIds.has(line.patchId) ? 'reverted' : ''}">
@@ -2115,11 +2458,47 @@ export class OxBoardAIChat extends LitElement {
2115
2458
  `
2116
2459
  : nothing}
2117
2460
  ${this.renderFoldNotice()}
2118
- <div class="composer ${hasMessages ? '' : 'no-actions'}">
2119
- ${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
2120
2468
  ? html`
2121
- <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`
2122
2500
  <button
2501
+ type="button"
2123
2502
  class="action"
2124
2503
  @click=${() => (this.examplesOpen = !this.examplesOpen)}
2125
2504
  title=${i18next.t('ai-assistant.text.show-examples-tooltip')}>
@@ -2128,8 +2507,34 @@ export class OxBoardAIChat extends LitElement {
2128
2507
  ? i18next.t('ai-assistant.button.close')
2129
2508
  : i18next.t('ai-assistant.button.examples')}
2130
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
+ )}
2131
2537
  </div>
2132
- ${this.examplesOpen ? this.renderInlineExamples() : nothing}
2133
2538
  `
2134
2539
  : nothing}
2135
2540
  <div class="input-row">
@@ -2167,7 +2572,7 @@ export class OxBoardAIChat extends LitElement {
2167
2572
  : nothing}
2168
2573
  </div>
2169
2574
  <button
2170
- ?disabled=${this.busy || !this.input.trim()}
2575
+ ?disabled=${this.busy || (!this.input.trim() && this.attachments.length === 0)}
2171
2576
  @click=${this.send}
2172
2577
  title=${i18next.t('ai-assistant.text.send-tooltip')}>
2173
2578
  ${this.busy
@@ -2492,7 +2897,7 @@ export class OxBoardAIChat extends LitElement {
2492
2897
  */
2493
2898
  private get exampleGroups(): Array<{ label: string; items: string[] }> {
2494
2899
  if (this.examples?.length) return this.examples
2495
- return OxBoardAIChat.EXAMPLE_GROUPS.map(group => ({
2900
+ return OxAssistantChat.EXAMPLE_GROUPS.map(group => ({
2496
2901
  label: i18next.t(group.labelKey),
2497
2902
  items: group.itemKeys.map(key => i18next.t(key))
2498
2903
  }))
@@ -2776,7 +3181,7 @@ export class OxBoardAIChat extends LitElement {
2776
3181
  */
2777
3182
  private startNewFromNotice = () => {
2778
3183
  this.foldNoticeDismissed = true
2779
- 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 }))
2780
3185
  }
2781
3186
 
2782
3187
  /**
@@ -2794,27 +3199,90 @@ export class OxBoardAIChat extends LitElement {
2794
3199
  ${items.map(p => {
2795
3200
  const key = this.proposalKey(p)
2796
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
+
2797
3210
  return html`
2798
3211
  <div class="proposal ${sent ? 'sent' : ''}">
2799
- <md-icon>bolt</md-icon>
3212
+ <md-icon>${p.icon || (p.source ? 'view_in_ar' : 'bolt')}</md-icon>
2800
3213
  <span class="pbody">
2801
3214
  <span class="plabel">${p.label || p.command}</span>
2802
3215
  ${p.reason ? html`<span class="preason">${p.reason}</span>` : nothing}
2803
3216
  </span>
2804
- <button
2805
- class="run"
2806
- ?disabled=${sent}
2807
- @click=${() => this.executeProposal(p, key)}>
2808
- ${sent ? i18next.t('ai-assistant.text.proposal-sent', { defaultValue: '실행 요청됨' })
2809
- : i18next.t('ai-assistant.button.run-proposal', { defaultValue: '실행' })}
2810
- </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
+ `}
2811
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}
2812
3245
  `
2813
3246
  })}
2814
3247
  </div>
2815
3248
  `
2816
3249
  }
2817
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
+
2818
3286
  /**
2819
3287
  * 제안 식별 키 — 같은 조치를 두 번 실행하지 않도록 **효과**(명령·대상·인자)로만 만든다.
2820
3288
  *
@@ -2845,7 +3313,7 @@ export class OxBoardAIChat extends LitElement {
2845
3313
  private executeProposal(p: any, key: string) {
2846
3314
  this.sentProposals = new Set(this.sentProposals).add(key)
2847
3315
  this.dispatchEvent(
2848
- new CustomEvent('board-ai-proposal-execute', {
3316
+ new CustomEvent('assistant-proposal-execute', {
2849
3317
  detail: { proposal: p, sessionId: this.sessionId },
2850
3318
  bubbles: true,
2851
3319
  composed: true
@@ -3124,7 +3592,15 @@ export class OxBoardAIChat extends LitElement {
3124
3592
 
3125
3593
  private async send() {
3126
3594
  const text = this.input.trim()
3127
- 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
+ : '')
3128
3604
  if (!this.sessionId && this.prepareSession) {
3129
3605
  this.busy = true
3130
3606
  try {
@@ -3156,13 +3632,13 @@ export class OxBoardAIChat extends LitElement {
3156
3632
  // 등장하는 것만 추출 — 지운 토큰 제외. inline 마커로 content 에 주입.
3157
3633
  const mentions: Array<{ token: string; refid: number }> = []
3158
3634
  for (const [token, refid] of this.pickedMentions.entries()) {
3159
- if (text.includes(`#${token}`)) mentions.push({ token, refid })
3635
+ if (promptText.includes(`#${token}`)) mentions.push({ token, refid })
3160
3636
  }
3161
3637
  const userMentions: Array<{ token: string; userId: string }> = []
3162
3638
  for (const [token, userId] of this.pickedUserMentions.entries()) {
3163
- if (text.includes(`@${token}`)) userMentions.push({ token, userId })
3639
+ if (promptText.includes(`@${token}`)) userMentions.push({ token, userId })
3164
3640
  }
3165
- let enrichedContent = injectMentionRefids(text, mentions)
3641
+ let enrichedContent = injectMentionRefids(promptText, mentions)
3166
3642
  enrichedContent = injectMentionUserIds(enrichedContent, userMentions)
3167
3643
 
3168
3644
  // optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
@@ -3171,7 +3647,19 @@ export class OxBoardAIChat extends LitElement {
3171
3647
 
3172
3648
  this.lines = [
3173
3649
  ...this.lines,
3174
- { 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
+ }
3175
3663
  ]
3176
3664
  this.input = ''
3177
3665
  this.busy = true
@@ -3181,6 +3669,24 @@ export class OxBoardAIChat extends LitElement {
3181
3669
  this.lines = [...this.lines, { role: 'assistant', content: '', pending: true, _localId: asstLocalId }]
3182
3670
 
3183
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
+ }
3184
3690
  // LLM 으로 보낼 history — user/assistant 만 (system 은 백엔드가 자동 합류).
3185
3691
  // pending placeholder 와 system 은 제외 (index 비의존 — 방송 append 와 무관).
3186
3692
  const history = this.lines
@@ -3248,6 +3754,21 @@ export class OxBoardAIChat extends LitElement {
3248
3754
  pending: false
3249
3755
  })
3250
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
+
3251
3772
  // 호스트로 patch 이벤트 전파
3252
3773
  if (out.patch) {
3253
3774
  this.dispatchEvent(
@@ -3297,6 +3818,7 @@ export class OxBoardAIChat extends LitElement {
3297
3818
 
3298
3819
  declare global {
3299
3820
  interface HTMLElementTagNameMap {
3300
- 'ox-board-ai-chat': OxBoardAIChat
3821
+ 'ox-assistant-chat': OxAssistantChat
3301
3822
  }
3302
3823
  }
3824
+