@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.
- package/client/components/{board-ai-chat.ts → assistant-chat.ts} +547 -25
- package/client/components/chat-input-builder.ts +1 -1
- package/client/index.ts +2 -3
- package/dist-client/components/{board-ai-chat.d.ts → assistant-chat.d.ts} +24 -3
- package/dist-client/components/{board-ai-chat.js → assistant-chat.js} +557 -72
- package/dist-client/components/assistant-chat.js.map +1 -0
- package/dist-client/components/{board-ai-chat.test.js → assistant-chat.test.js} +1 -1
- package/dist-client/components/assistant-chat.test.js.map +1 -0
- package/dist-client/components/chat-input-builder.js +1 -1
- package/dist-client/components/chat-input-builder.js.map +1 -1
- package/dist-client/index.d.ts +2 -3
- package/dist-client/index.js +2 -3
- package/dist-client/index.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/assistant-chat-resolver.js +12 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/assistant-chat-resolver.ts +13 -0
- package/test/assistant-request-context.test.ts +1 -1
- package/test/assistant-session-toolbar.test.ts +1 -1
- package/translations/en.json +6 -1
- package/translations/ko.json +6 -1
- package/dist-client/components/board-ai-chat.js.map +0 -1
- package/dist-client/components/board-ai-chat.test.js.map +0 -1
- /package/client/components/{board-ai-chat.test.ts → assistant-chat.test.ts} +0 -0
- /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-
|
|
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-
|
|
226
|
-
export class
|
|
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
|
|
2119
|
-
${
|
|
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="
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
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('
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
3639
|
+
if (promptText.includes(`@${token}`)) userMentions.push({ token, userId })
|
|
3164
3640
|
}
|
|
3165
|
-
let enrichedContent = injectMentionRefids(
|
|
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
|
-
{
|
|
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-
|
|
3821
|
+
'ox-assistant-chat': OxAssistantChat
|
|
3301
3822
|
}
|
|
3302
3823
|
}
|
|
3824
|
+
|