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