@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.
- package/client/components/{board-ai-chat.ts → assistant-chat.ts} +589 -18
- package/client/components/chat-defaults.ts +24 -0
- 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} +39 -3
- package/dist-client/components/{board-ai-chat.js → assistant-chat.js} +597 -66
- 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-defaults.d.ts +22 -0
- package/dist-client/components/chat-defaults.js +4 -0
- package/dist-client/components/chat-defaults.js.map +1 -1
- 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 +4 -4
- 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
|
|
@@ -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
|
|
2119
|
-
${
|
|
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="
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
3688
|
+
if (promptText.includes(`@${token}`)) userMentions.push({ token, userId })
|
|
3164
3689
|
}
|
|
3165
|
-
let enrichedContent = injectMentionRefids(
|
|
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
|
-
{
|
|
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-
|
|
3870
|
+
'ox-assistant-chat': OxAssistantChat
|
|
3301
3871
|
}
|
|
3302
3872
|
}
|
|
3873
|
+
|