@things-factory/ai-assistant 10.1.13 → 10.1.16

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 (31) hide show
  1. package/client/components/{board-ai-chat.ts → assistant-chat.ts} +589 -18
  2. package/client/components/chat-defaults.ts +24 -0
  3. package/client/components/chat-input-builder.ts +1 -1
  4. package/client/index.ts +2 -3
  5. package/dist-client/components/{board-ai-chat.d.ts → assistant-chat.d.ts} +39 -3
  6. package/dist-client/components/{board-ai-chat.js → assistant-chat.js} +597 -66
  7. package/dist-client/components/assistant-chat.js.map +1 -0
  8. package/dist-client/components/{board-ai-chat.test.js → assistant-chat.test.js} +1 -1
  9. package/dist-client/components/assistant-chat.test.js.map +1 -0
  10. package/dist-client/components/chat-defaults.d.ts +22 -0
  11. package/dist-client/components/chat-defaults.js +4 -0
  12. package/dist-client/components/chat-defaults.js.map +1 -1
  13. package/dist-client/components/chat-input-builder.js +1 -1
  14. package/dist-client/components/chat-input-builder.js.map +1 -1
  15. package/dist-client/index.d.ts +2 -3
  16. package/dist-client/index.js +2 -3
  17. package/dist-client/index.js.map +1 -1
  18. package/dist-client/tsconfig.tsbuildinfo +1 -1
  19. package/dist-server/service/assistant-chat-resolver.js +12 -0
  20. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  21. package/dist-server/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +4 -4
  23. package/server/service/assistant-chat-resolver.ts +13 -0
  24. package/test/assistant-request-context.test.ts +1 -1
  25. package/test/assistant-session-toolbar.test.ts +1 -1
  26. package/translations/en.json +6 -1
  27. package/translations/ko.json +6 -1
  28. package/dist-client/components/board-ai-chat.js.map +0 -1
  29. package/dist-client/components/board-ai-chat.test.js.map +0 -1
  30. /package/client/components/{board-ai-chat.test.ts → assistant-chat.test.ts} +0 -0
  31. /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
@@ -451,6 +570,44 @@ export class OxBoardAIChat extends LitElement {
451
570
  @state()
452
571
  private sentProposals = new Set<string>()
453
572
 
573
+ /** Proposal keys the host has reported staged. Only the host knows; see `acknowledgeProposalStaged`. */
574
+ @state()
575
+ private stagedProposals = new Set<string>()
576
+
577
+ /**
578
+ * The host reports that it put this candidate somewhere the person can look at it.
579
+ *
580
+ * Staging happens outside this component — a dock catches `assistant-proposal-preview`, hands
581
+ * the candidate to a viewer, and only that host knows whether it worked, or whether it has a
582
+ * viewer at all. So the card says nothing until the host calls this, and it then says it in the
583
+ * words the host registered (`chatDefaults.stagedNoticeKey`).
584
+ *
585
+ * Safe to call for a candidate the card never showed; the key simply never matches.
586
+ */
587
+ acknowledgeProposalStaged(proposal: any): void {
588
+ /*
589
+ Silence has two meanings and they are not the same defect. A host that never calls this is
590
+ telling us it does not know, and drawing nothing is right. A host that calls it without
591
+ registering `stagedNoticeKey` meant to say something and left out the words -- staying quiet
592
+ about that reads to whoever wired it as "the badge just doesn't appear here".
593
+
594
+ The screen still shows nothing either way. There is no sentence to show, and inventing one is
595
+ what this whole correction removed.
596
+ */
597
+ if (!chatDefaults.stagedNoticeKey && !OxAssistantChat.warnedMissingStagedNotice) {
598
+ OxAssistantChat.warnedMissingStagedNotice = true
599
+ console.warn(
600
+ '[assistant-chat] a host reported a proposal staged but registered no `stagedNoticeKey`, ' +
601
+ 'so the card can say nothing. Pass one to `registerChatDefaults` from the host.'
602
+ )
603
+ }
604
+
605
+ this.stagedProposals = new Set(this.stagedProposals).add(this.proposalKey(proposal))
606
+ }
607
+
608
+ /** Once per page: the miss is a wiring mistake, and repeating it per candidate buries it. */
609
+ private static warnedMissingStagedNotice = false
610
+
454
611
  /**
455
612
  * 이 대화가 길어져 앞부분이 프롬프트에서 접혔다는 **사실**(서버 보고). 주제 이탈을 판정한 것이
456
613
  * 아니다 — 판정하면 오탐이 생기고, 오탐이 생기면 사용자는 안내 자체를 무시한다.
@@ -1332,6 +1489,63 @@ export class OxBoardAIChat extends LitElement {
1332
1489
  .proposal.sent {
1333
1490
  border-left-color: var(--md-sys-color-outline, #94a3b8);
1334
1491
  }
1492
+ .proposal .staged-badge {
1493
+ display: inline-flex;
1494
+ align-items: center;
1495
+ gap: 3px;
1496
+ padding: 3px 8px;
1497
+ border-radius: 999px;
1498
+ background: rgba(15, 118, 110, 0.1);
1499
+ color: var(--md-sys-color-primary, #0f766e);
1500
+ font-size: 11px;
1501
+ font-weight: 600;
1502
+ flex-shrink: 0;
1503
+ }
1504
+ .proposal .staged-badge md-icon {
1505
+ --md-icon-size: 13px;
1506
+ color: inherit;
1507
+ }
1508
+
1509
+ .proposal-choices {
1510
+ display: flex;
1511
+ flex-wrap: wrap;
1512
+ gap: 6px;
1513
+ margin-top: 6px;
1514
+ padding: 6px 8px;
1515
+ background: var(--md-sys-color-surface-container-lowest, #ffffff);
1516
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1517
+ border-radius: 8px;
1518
+ }
1519
+ .proposal-choices .choice-chip {
1520
+ display: inline-flex;
1521
+ align-items: center;
1522
+ gap: 4px;
1523
+ padding: 4px 9px;
1524
+ border-radius: 999px;
1525
+ border: 1px solid var(--md-sys-color-outline-variant, #cbd5e1);
1526
+ background: var(--md-sys-color-surface-container-low, #f8fafc);
1527
+ color: var(--md-sys-color-on-surface, #1e293b);
1528
+ font-family: inherit;
1529
+ font-size: 11px;
1530
+ font-weight: 550;
1531
+ cursor: pointer;
1532
+ transition: all 0.14s ease-in-out;
1533
+ }
1534
+ .proposal-choices .choice-chip:hover {
1535
+ background: var(--md-sys-color-surface-container-high, #e2e8f0);
1536
+ border-color: var(--md-sys-color-primary, #0f766e);
1537
+ color: var(--md-sys-color-primary, #0f766e);
1538
+ }
1539
+ .proposal-choices .choice-chip.primary {
1540
+ background: var(--md-sys-color-primary-container, #ccfbf1);
1541
+ color: var(--md-sys-color-on-primary-container, #0f766e);
1542
+ border-color: var(--md-sys-color-primary, #0f766e);
1543
+ font-weight: 650;
1544
+ }
1545
+ .proposal-choices .choice-chip md-icon {
1546
+ --md-icon-size: 13px;
1547
+ color: inherit;
1548
+ }
1335
1549
 
1336
1550
  /* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
1337
1551
  .grounding-warning {
@@ -1634,10 +1848,156 @@ export class OxBoardAIChat extends LitElement {
1634
1848
 
1635
1849
  /* ── Composer (actions + input 통합 영역) ──────────── */
1636
1850
  .composer {
1851
+ position: relative;
1637
1852
  border-top: 1px solid var(--md-sys-color-outline-variant, #f1f5f9);
1638
1853
  background: var(--md-sys-color-surface, #ffffff);
1639
1854
  display: flex;
1640
1855
  flex-direction: column;
1856
+ transition: background 0.15s, border-color 0.15s;
1857
+ }
1858
+ .composer.drag-over {
1859
+ background: var(--md-sys-color-surface-container, #f1f5f9);
1860
+ border-top-color: var(--md-sys-color-primary, #0284c7);
1861
+ }
1862
+ .drag-drop-overlay {
1863
+ position: absolute;
1864
+ top: 0;
1865
+ left: 0;
1866
+ right: 0;
1867
+ bottom: 0;
1868
+ background: rgba(255, 255, 255, 0.94);
1869
+ backdrop-filter: blur(2px);
1870
+ z-index: 10;
1871
+ display: flex;
1872
+ flex-direction: column;
1873
+ align-items: center;
1874
+ justify-content: center;
1875
+ gap: 6px;
1876
+ border: 2px dashed var(--md-sys-color-primary, #0284c7);
1877
+ border-radius: 4px;
1878
+ color: var(--md-sys-color-primary, #0284c7);
1879
+ font-size: 13px;
1880
+ font-weight: 500;
1881
+ pointer-events: none;
1882
+ }
1883
+ .drag-drop-overlay md-icon {
1884
+ --md-icon-size: 28px;
1885
+ }
1886
+ .attachment-strip {
1887
+ display: flex;
1888
+ flex-wrap: wrap;
1889
+ gap: 6px;
1890
+ padding: 6px 14px 2px;
1891
+ max-height: 120px;
1892
+ overflow-y: auto;
1893
+ }
1894
+ .attachment-chip {
1895
+ display: flex;
1896
+ align-items: center;
1897
+ gap: 6px;
1898
+ background: var(--md-sys-color-surface-container, #f1f5f9);
1899
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1900
+ border-radius: 8px;
1901
+ padding: 3px 6px 3px 4px;
1902
+ max-width: 220px;
1903
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
1904
+ }
1905
+ .attachment-thumb {
1906
+ width: 28px;
1907
+ height: 28px;
1908
+ object-fit: cover;
1909
+ border-radius: 4px;
1910
+ flex: none;
1911
+ }
1912
+ .attachment-icon {
1913
+ --md-icon-size: 20px;
1914
+ color: var(--md-sys-color-primary, #0284c7);
1915
+ flex: none;
1916
+ }
1917
+ .attachment-meta {
1918
+ display: flex;
1919
+ flex-direction: column;
1920
+ min-width: 0;
1921
+ flex: 1;
1922
+ }
1923
+ .attachment-name {
1924
+ font-size: 11px;
1925
+ font-weight: 500;
1926
+ color: var(--md-sys-color-on-surface, #0f172a);
1927
+ overflow: hidden;
1928
+ text-overflow: ellipsis;
1929
+ white-space: nowrap;
1930
+ }
1931
+ .attachment-size {
1932
+ font-size: 9px;
1933
+ color: var(--md-sys-color-outline, #64748b);
1934
+ }
1935
+ .attachment-remove {
1936
+ all: unset;
1937
+ display: flex;
1938
+ align-items: center;
1939
+ justify-content: center;
1940
+ width: 18px;
1941
+ height: 18px;
1942
+ border-radius: 50%;
1943
+ cursor: pointer;
1944
+ color: var(--md-sys-color-outline, #64748b);
1945
+ transition: background 0.15s, color 0.15s;
1946
+ flex: none;
1947
+ }
1948
+ .attachment-remove:hover {
1949
+ background: var(--md-sys-color-surface-container-highest, #cbd5e1);
1950
+ color: var(--md-sys-color-error, #ef4444);
1951
+ }
1952
+ .attachment-remove md-icon {
1953
+ --md-icon-size: 14px;
1954
+ }
1955
+ .msg-attachments {
1956
+ display: flex;
1957
+ flex-wrap: wrap;
1958
+ gap: 8px;
1959
+ margin-top: 8px;
1960
+ }
1961
+ .msg-attachment-img-wrap {
1962
+ border-radius: 8px;
1963
+ overflow: hidden;
1964
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1965
+ background: var(--md-sys-color-surface-container-lowest, #ffffff);
1966
+ max-width: 240px;
1967
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
1968
+ display: flex;
1969
+ flex-direction: column;
1970
+ }
1971
+ .msg-attachment-img-wrap img {
1972
+ max-width: 100%;
1973
+ max-height: 160px;
1974
+ object-fit: cover;
1975
+ display: block;
1976
+ }
1977
+ .msg-attachment-label {
1978
+ padding: 4px 8px;
1979
+ font-size: 10px;
1980
+ color: var(--md-sys-color-on-surface-variant, #64748b);
1981
+ background: var(--md-sys-color-surface-container, #f8fafc);
1982
+ overflow: hidden;
1983
+ text-overflow: ellipsis;
1984
+ white-space: nowrap;
1985
+ border-top: 1px solid var(--md-sys-color-outline-variant, #f1f5f9);
1986
+ }
1987
+ .msg-attachment-file-chip {
1988
+ display: flex;
1989
+ align-items: center;
1990
+ gap: 6px;
1991
+ padding: 6px 10px;
1992
+ border-radius: 8px;
1993
+ background: var(--md-sys-color-surface-container, #f8fafc);
1994
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1995
+ font-size: 11px;
1996
+ color: var(--md-sys-color-on-surface, #0f172a);
1997
+ }
1998
+ .msg-attachment-file-chip md-icon {
1999
+ --md-icon-size: 18px;
2000
+ color: var(--md-sys-color-primary, #0284c7);
1641
2001
  }
1642
2002
 
1643
2003
  /* ── Mini action row (메시지 시작 후) ──────────────── */
@@ -2065,6 +2425,27 @@ export class OxBoardAIChat extends LitElement {
2065
2425
  >${content}</span
2066
2426
  >`
2067
2427
  : content}
2428
+ ${line.attachments && line.attachments.length > 0
2429
+ ? html`
2430
+ <div class="msg-attachments">
2431
+ ${line.attachments.map(att =>
2432
+ att.type?.startsWith('image/') || att.url?.startsWith('data:image/') || att.base64
2433
+ ? html`
2434
+ <div class="msg-attachment-img-wrap" title=${att.name}>
2435
+ <img src=${att.url || att.base64} alt=${att.name} />
2436
+ <span class="msg-attachment-label">${att.name}</span>
2437
+ </div>
2438
+ `
2439
+ : html`
2440
+ <div class="msg-attachment-file-chip" title=${att.name}>
2441
+ <md-icon>description</md-icon>
2442
+ <span>${att.name}</span>
2443
+ </div>
2444
+ `
2445
+ )}
2446
+ </div>
2447
+ `
2448
+ : nothing}
2068
2449
  ${line.patchId
2069
2450
  ? html`
2070
2451
  <div class="summary ${this.revertedPatchIds.has(line.patchId) ? 'reverted' : ''}">
@@ -2115,11 +2496,47 @@ export class OxBoardAIChat extends LitElement {
2115
2496
  `
2116
2497
  : nothing}
2117
2498
  ${this.renderFoldNotice()}
2118
- <div class="composer ${hasMessages ? '' : 'no-actions'}">
2119
- ${hasMessages
2499
+ <div
2500
+ class="composer ${this.dragOver ? 'drag-over' : ''}"
2501
+ @dragover=${(e: DragEvent) => this.onComposerDragOver(e)}
2502
+ @dragleave=${(e: DragEvent) => this.onComposerDragLeave(e)}
2503
+ @drop=${(e: DragEvent) => this.onComposerDrop(e)}
2504
+ @paste=${(e: ClipboardEvent) => this.onComposerPaste(e)}>
2505
+ ${this.dragOver
2120
2506
  ? html`
2121
- <div class="actions-row">
2507
+ <div class="drag-drop-overlay">
2508
+ <md-icon>cloud_upload</md-icon>
2509
+ <span>${i18next.t('ai-assistant.text.drop-files-here', { defaultValue: '여기에 이미지나 파일을 놓으세요' })}</span>
2510
+ </div>
2511
+ `
2512
+ : nothing}
2513
+ <div class="actions-row">
2514
+ <input
2515
+ id="chat-file-input"
2516
+ type="file"
2517
+ multiple
2518
+ accept="image/*,.json,.txt,.csv"
2519
+ style="display: none"
2520
+ @change=${(e: Event) => {
2521
+ const input = e.target as HTMLInputElement
2522
+ if (input.files?.length) {
2523
+ this.onAttachFiles(input.files)
2524
+ input.value = ''
2525
+ }
2526
+ }}
2527
+ />
2528
+ <button
2529
+ type="button"
2530
+ class="action"
2531
+ @click=${() => this.renderRoot.querySelector<HTMLInputElement>('#chat-file-input')?.click()}
2532
+ title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: '이미지 또는 파일 첨부 (클립보드 붙여넣기·드래그앤드롭 지원)' })}>
2533
+ <md-icon>attach_file</md-icon>
2534
+ ${i18next.t('ai-assistant.button.attach', { defaultValue: '첨부' })}
2535
+ </button>
2536
+ ${hasMessages
2537
+ ? html`
2122
2538
  <button
2539
+ type="button"
2123
2540
  class="action"
2124
2541
  @click=${() => (this.examplesOpen = !this.examplesOpen)}
2125
2542
  title=${i18next.t('ai-assistant.text.show-examples-tooltip')}>
@@ -2128,8 +2545,34 @@ export class OxBoardAIChat extends LitElement {
2128
2545
  ? i18next.t('ai-assistant.button.close')
2129
2546
  : i18next.t('ai-assistant.button.examples')}
2130
2547
  </button>
2548
+ `
2549
+ : nothing}
2550
+ </div>
2551
+ ${this.examplesOpen ? this.renderInlineExamples() : nothing}
2552
+ ${this.attachments.length > 0
2553
+ ? html`
2554
+ <div class="attachment-strip">
2555
+ ${this.attachments.map(
2556
+ att => html`
2557
+ <div class="attachment-chip" title=${att.name}>
2558
+ ${att.type.startsWith('image/')
2559
+ ? html`<img class="attachment-thumb" src=${att.url} alt=${att.name} />`
2560
+ : html`<md-icon class="attachment-icon">description</md-icon>`}
2561
+ <div class="attachment-meta">
2562
+ <span class="attachment-name">${att.name}</span>
2563
+ <span class="attachment-size">${formatFileSize(att.size)}</span>
2564
+ </div>
2565
+ <button
2566
+ type="button"
2567
+ class="attachment-remove"
2568
+ title=${i18next.t('ai-assistant.button.remove-attachment', { defaultValue: '첨부 삭제' })}
2569
+ @click=${() => this.removeAttachment(att.id)}>
2570
+ <md-icon>close</md-icon>
2571
+ </button>
2572
+ </div>
2573
+ `
2574
+ )}
2131
2575
  </div>
2132
- ${this.examplesOpen ? this.renderInlineExamples() : nothing}
2133
2576
  `
2134
2577
  : nothing}
2135
2578
  <div class="input-row">
@@ -2167,7 +2610,7 @@ export class OxBoardAIChat extends LitElement {
2167
2610
  : nothing}
2168
2611
  </div>
2169
2612
  <button
2170
- ?disabled=${this.busy || !this.input.trim()}
2613
+ ?disabled=${this.busy || (!this.input.trim() && this.attachments.length === 0)}
2171
2614
  @click=${this.send}
2172
2615
  title=${i18next.t('ai-assistant.text.send-tooltip')}>
2173
2616
  ${this.busy
@@ -2492,7 +2935,7 @@ export class OxBoardAIChat extends LitElement {
2492
2935
  */
2493
2936
  private get exampleGroups(): Array<{ label: string; items: string[] }> {
2494
2937
  if (this.examples?.length) return this.examples
2495
- return OxBoardAIChat.EXAMPLE_GROUPS.map(group => ({
2938
+ return OxAssistantChat.EXAMPLE_GROUPS.map(group => ({
2496
2939
  label: i18next.t(group.labelKey),
2497
2940
  items: group.itemKeys.map(key => i18next.t(key))
2498
2941
  }))
@@ -2776,7 +3219,7 @@ export class OxBoardAIChat extends LitElement {
2776
3219
  */
2777
3220
  private startNewFromNotice = () => {
2778
3221
  this.foldNoticeDismissed = true
2779
- this.dispatchEvent(new CustomEvent('board-ai-start-new-session', { bubbles: true, composed: true }))
3222
+ this.dispatchEvent(new CustomEvent('assistant-start-new-session', { bubbles: true, composed: true }))
2780
3223
  }
2781
3224
 
2782
3225
  /**
@@ -2794,13 +3237,27 @@ export class OxBoardAIChat extends LitElement {
2794
3237
  ${items.map(p => {
2795
3238
  const key = this.proposalKey(p)
2796
3239
  const sent = this.sentProposals.has(key)
3240
+ /*
3241
+ Follow-up chips are the host's words, never this component's. The fallback that used
3242
+ to live here named a 3D modeller's operations in hardcoded Korean, and it reached the
3243
+ plant and twin chats too.
3244
+ */
3245
+ const rawChoices: any[] = Array.isArray(p.choices) ? p.choices : (chatDefaults.proposalChoices ?? [])
3246
+
2797
3247
  return html`
2798
3248
  <div class="proposal ${sent ? 'sent' : ''}">
2799
- <md-icon>bolt</md-icon>
3249
+ <md-icon>${p.icon || (p.source ? 'view_in_ar' : 'bolt')}</md-icon>
2800
3250
  <span class="pbody">
2801
3251
  <span class="plabel">${p.label || p.command}</span>
2802
3252
  ${p.reason ? html`<span class="preason">${p.reason}</span>` : nothing}
2803
3253
  </span>
3254
+ ${/*
3255
+ The run button and the follow-up chips answer different questions — "is there
3256
+ something to apply" and "what else could I ask for" — and they used to share one
3257
+ ternary on `rawChoices.length`. A proposal that carried chips lost its button, so
3258
+ the one action the card exists for became unreachable while the model kept
3259
+ telling people to press it.
3260
+ */ ''}
2804
3261
  <button
2805
3262
  class="run"
2806
3263
  ?disabled=${sent}
@@ -2808,13 +3265,73 @@ export class OxBoardAIChat extends LitElement {
2808
3265
  ${sent ? i18next.t('ai-assistant.text.proposal-sent', { defaultValue: '실행 요청됨' })
2809
3266
  : i18next.t('ai-assistant.button.run-proposal', { defaultValue: '실행' })}
2810
3267
  </button>
3268
+ ${/*
3269
+ Drawn only where the host has said it staged this candidate, in the host's own
3270
+ words. Not knowing is not a claim: with no word from the host, nothing is drawn.
3271
+ This badge used to read "there are chips" as "the model is in the 3D viewer" and
3272
+ say so, in a component that plant and twin also use.
3273
+ */ ''}
3274
+ ${this.stagedProposals.has(key) && chatDefaults.stagedNoticeKey ? html`
3275
+ <span class="staged-badge">
3276
+ <md-icon>check_circle</md-icon>
3277
+ <span>${i18next.t(chatDefaults.stagedNoticeKey)}</span>
3278
+ </span>
3279
+ ` : nothing}
2811
3280
  </div>
3281
+ ${rawChoices.length ? html`
3282
+ <div class="proposal-choices" role="group" aria-label="대화 선택지">
3283
+ ${rawChoices.map((c: any) => html`
3284
+ <button
3285
+ class="choice-chip ${c.primary ? 'primary' : ''}"
3286
+ title=${c.prompt || c.label}
3287
+ @click=${() => this.onChoiceClick(c, p, key)}>
3288
+ ${c.icon ? html`<md-icon>${c.icon}</md-icon>` : nothing}
3289
+ <span>${c.label}</span>
3290
+ </button>
3291
+ `)}
3292
+ </div>
3293
+ ` : nothing}
2812
3294
  `
2813
3295
  })}
2814
3296
  </div>
2815
3297
  `
2816
3298
  }
2817
3299
 
3300
+ private onChoiceClick(choice: any, proposal: any, key: string) {
3301
+ if (choice.action === 'save') {
3302
+ this.dispatchEvent(
3303
+ new CustomEvent('assistant-choice', {
3304
+ detail: { action: 'save', choice, proposal, sessionId: this.sessionId },
3305
+ bubbles: true,
3306
+ composed: true
3307
+ })
3308
+ )
3309
+ this.executeProposal(proposal, key)
3310
+ return
3311
+ }
3312
+
3313
+ if (choice.action === 'prompt' && choice.prompt) {
3314
+ this.input = choice.prompt
3315
+ this.dispatchEvent(
3316
+ new CustomEvent('assistant-choice', {
3317
+ detail: { action: 'prompt', choice, proposal, sessionId: this.sessionId },
3318
+ bubbles: true,
3319
+ composed: true
3320
+ })
3321
+ )
3322
+ void this.send()
3323
+ return
3324
+ }
3325
+
3326
+ this.dispatchEvent(
3327
+ new CustomEvent('assistant-choice', {
3328
+ detail: { action: choice.action || 'custom', choice, proposal, sessionId: this.sessionId },
3329
+ bubbles: true,
3330
+ composed: true
3331
+ })
3332
+ )
3333
+ }
3334
+
2818
3335
  /**
2819
3336
  * 제안 식별 키 — 같은 조치를 두 번 실행하지 않도록 **효과**(명령·대상·인자)로만 만든다.
2820
3337
  *
@@ -2845,7 +3362,7 @@ export class OxBoardAIChat extends LitElement {
2845
3362
  private executeProposal(p: any, key: string) {
2846
3363
  this.sentProposals = new Set(this.sentProposals).add(key)
2847
3364
  this.dispatchEvent(
2848
- new CustomEvent('board-ai-proposal-execute', {
3365
+ new CustomEvent('assistant-proposal-execute', {
2849
3366
  detail: { proposal: p, sessionId: this.sessionId },
2850
3367
  bubbles: true,
2851
3368
  composed: true
@@ -3124,7 +3641,15 @@ export class OxBoardAIChat extends LitElement {
3124
3641
 
3125
3642
  private async send() {
3126
3643
  const text = this.input.trim()
3127
- if (!text || this.busy) return
3644
+ const attachmentsToSend = [...this.attachments]
3645
+ if ((!text && attachmentsToSend.length === 0) || this.busy) return
3646
+ this.attachments = []
3647
+
3648
+ const promptText = text || (attachmentsToSend.length > 0
3649
+ ? (attachmentsToSend[0].type.startsWith('image/')
3650
+ ? '첨부된 이미지를 참고하여 제안해 주세요.'
3651
+ : '첨부된 파일을 확인해 주세요.')
3652
+ : '')
3128
3653
  if (!this.sessionId && this.prepareSession) {
3129
3654
  this.busy = true
3130
3655
  try {
@@ -3156,13 +3681,13 @@ export class OxBoardAIChat extends LitElement {
3156
3681
  // 등장하는 것만 추출 — 지운 토큰 제외. inline 마커로 content 에 주입.
3157
3682
  const mentions: Array<{ token: string; refid: number }> = []
3158
3683
  for (const [token, refid] of this.pickedMentions.entries()) {
3159
- if (text.includes(`#${token}`)) mentions.push({ token, refid })
3684
+ if (promptText.includes(`#${token}`)) mentions.push({ token, refid })
3160
3685
  }
3161
3686
  const userMentions: Array<{ token: string; userId: string }> = []
3162
3687
  for (const [token, userId] of this.pickedUserMentions.entries()) {
3163
- if (text.includes(`@${token}`)) userMentions.push({ token, userId })
3688
+ if (promptText.includes(`@${token}`)) userMentions.push({ token, userId })
3164
3689
  }
3165
- let enrichedContent = injectMentionRefids(text, mentions)
3690
+ let enrichedContent = injectMentionRefids(promptText, mentions)
3166
3691
  enrichedContent = injectMentionUserIds(enrichedContent, userMentions)
3167
3692
 
3168
3693
  // optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
@@ -3171,7 +3696,19 @@ export class OxBoardAIChat extends LitElement {
3171
3696
 
3172
3697
  this.lines = [
3173
3698
  ...this.lines,
3174
- { role: 'user', content: enrichedContent, _localId: userLocalId, senderEmail: this._myEmail }
3699
+ {
3700
+ role: 'user',
3701
+ content: enrichedContent,
3702
+ attachments: attachmentsToSend.map(a => ({
3703
+ name: a.name,
3704
+ size: a.size,
3705
+ type: a.type,
3706
+ url: a.url,
3707
+ base64: a.base64
3708
+ })),
3709
+ _localId: userLocalId,
3710
+ senderEmail: this._myEmail
3711
+ }
3175
3712
  ]
3176
3713
  this.input = ''
3177
3714
  this.busy = true
@@ -3181,6 +3718,24 @@ export class OxBoardAIChat extends LitElement {
3181
3718
  this.lines = [...this.lines, { role: 'assistant', content: '', pending: true, _localId: asstLocalId }]
3182
3719
 
3183
3720
  try {
3721
+ if (attachmentsToSend.length > 0) {
3722
+ requestContext.hostContext = {
3723
+ ...requestContext.hostContext,
3724
+ attachments: attachmentsToSend.map(a => ({
3725
+ name: a.name,
3726
+ size: a.size,
3727
+ mediaType: a.type,
3728
+ data: a.base64
3729
+ })),
3730
+ image: attachmentsToSend.find(a => a.type.startsWith('image/'))
3731
+ ? {
3732
+ name: attachmentsToSend.find(a => a.type.startsWith('image/'))!.name,
3733
+ mediaType: attachmentsToSend.find(a => a.type.startsWith('image/'))!.type,
3734
+ data: attachmentsToSend.find(a => a.type.startsWith('image/'))!.base64
3735
+ }
3736
+ : undefined
3737
+ }
3738
+ }
3184
3739
  // LLM 으로 보낼 history — user/assistant 만 (system 은 백엔드가 자동 합류).
3185
3740
  // pending placeholder 와 system 은 제외 (index 비의존 — 방송 append 와 무관).
3186
3741
  const history = this.lines
@@ -3248,6 +3803,21 @@ export class OxBoardAIChat extends LitElement {
3248
3803
  pending: false
3249
3804
  })
3250
3805
 
3806
+ // 제안 자동 프리뷰 전파 (3D 뷰어 자동 반영)
3807
+ if (Array.isArray(out.proposals) && out.proposals.length > 0) {
3808
+ for (const p of out.proposals) {
3809
+ if (p?.source || p?.autoStage) {
3810
+ this.dispatchEvent(
3811
+ new CustomEvent('assistant-proposal-preview', {
3812
+ detail: { proposal: p, sessionId: out.sessionId },
3813
+ bubbles: true,
3814
+ composed: true
3815
+ })
3816
+ )
3817
+ }
3818
+ }
3819
+ }
3820
+
3251
3821
  // 호스트로 patch 이벤트 전파
3252
3822
  if (out.patch) {
3253
3823
  this.dispatchEvent(
@@ -3297,6 +3867,7 @@ export class OxBoardAIChat extends LitElement {
3297
3867
 
3298
3868
  declare global {
3299
3869
  interface HTMLElementTagNameMap {
3300
- 'ox-board-ai-chat': OxBoardAIChat
3870
+ 'ox-assistant-chat': OxAssistantChat
3301
3871
  }
3302
3872
  }
3873
+