@things-factory/ai-assistant 10.1.6
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.test.ts +149 -0
- package/client/components/board-ai-chat.ts +3228 -0
- package/client/components/chat-defaults.ts +27 -0
- package/client/components/chat-echo-dedup.test.ts +230 -0
- package/client/components/chat-echo-dedup.ts +155 -0
- package/client/components/chat-input-builder.ts +69 -0
- package/client/components/markdown.test.ts +518 -0
- package/client/components/markdown.ts +508 -0
- package/client/components/mention-popup-helpers.ts +433 -0
- package/client/components/mention-popup.test.ts +588 -0
- package/client/components/mention-popup.ts +238 -0
- package/client/index.ts +14 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/board-edit-patch.ts +374 -0
- package/dist-client/components/board-ai-chat.d.ts +423 -0
- package/dist-client/components/board-ai-chat.js +3034 -0
- package/dist-client/components/board-ai-chat.js.map +1 -0
- package/dist-client/components/board-ai-chat.test.d.ts +1 -0
- package/dist-client/components/board-ai-chat.test.js +138 -0
- package/dist-client/components/board-ai-chat.test.js.map +1 -0
- package/dist-client/components/chat-defaults.d.ts +8 -0
- package/dist-client/components/chat-defaults.js +22 -0
- package/dist-client/components/chat-defaults.js.map +1 -0
- package/dist-client/components/chat-echo-dedup.d.ts +54 -0
- package/dist-client/components/chat-echo-dedup.js +115 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -0
- package/dist-client/components/chat-echo-dedup.test.d.ts +1 -0
- package/dist-client/components/chat-echo-dedup.test.js +203 -0
- package/dist-client/components/chat-echo-dedup.test.js.map +1 -0
- package/dist-client/components/chat-input-builder.d.ts +55 -0
- package/dist-client/components/chat-input-builder.js +32 -0
- package/dist-client/components/chat-input-builder.js.map +1 -0
- package/dist-client/components/markdown.d.ts +60 -0
- package/dist-client/components/markdown.js +422 -0
- package/dist-client/components/markdown.js.map +1 -0
- package/dist-client/components/markdown.test.d.ts +1 -0
- package/dist-client/components/markdown.test.js +418 -0
- package/dist-client/components/markdown.test.js.map +1 -0
- package/dist-client/components/mention-popup-helpers.d.ts +165 -0
- package/dist-client/components/mention-popup-helpers.js +330 -0
- package/dist-client/components/mention-popup-helpers.js.map +1 -0
- package/dist-client/components/mention-popup.d.ts +35 -0
- package/dist-client/components/mention-popup.js +223 -0
- package/dist-client/components/mention-popup.js.map +1 -0
- package/dist-client/components/mention-popup.test.d.ts +1 -0
- package/dist-client/components/mention-popup.test.js +474 -0
- package/dist-client/components/mention-popup.test.js.map +1 -0
- package/dist-client/index.d.ts +14 -0
- package/dist-client/index.js +15 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/board-edit-patch.d.ts +157 -0
- package/dist-client/utils/board-edit-patch.js +284 -0
- package/dist-client/utils/board-edit-patch.js.map +1 -0
- package/dist-server/index.d.ts +28 -0
- package/dist-server/index.js +32 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
- package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
- package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
- package/dist-server/service/chat-message/chat-message-publish.js +27 -0
- package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
- package/dist-server/service/chat-message/chat-message-subscription.js +57 -0
- package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
- package/dist-server/service/chat-message/chat-message.d.ts +68 -0
- package/dist-server/service/chat-message/chat-message.js +172 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -0
- package/dist-server/service/chat-message/fold-history.d.ts +43 -0
- package/dist-server/service/chat-message/fold-history.js +33 -0
- package/dist-server/service/chat-message/fold-history.js.map +1 -0
- package/dist-server/service/chat-message/history-summary.d.ts +43 -0
- package/dist-server/service/chat-message/history-summary.js +77 -0
- package/dist-server/service/chat-message/history-summary.js.map +1 -0
- package/dist-server/service/chat-message/index.d.ts +5 -0
- package/dist-server/service/chat-message/index.js +12 -0
- package/dist-server/service/chat-message/index.js.map +1 -0
- package/dist-server/service/chat-message/llm-history.d.ts +46 -0
- package/dist-server/service/chat-message/llm-history.js +87 -0
- package/dist-server/service/chat-message/llm-history.js.map +1 -0
- package/dist-server/service/chat-session/chat-session.d.ts +60 -0
- package/dist-server/service/chat-session/chat-session.js +159 -0
- package/dist-server/service/chat-session/chat-session.js.map +1 -0
- package/dist-server/service/chat-session/index.d.ts +3 -0
- package/dist-server/service/chat-session/index.js +7 -0
- package/dist-server/service/chat-session/index.js.map +1 -0
- package/dist-server/service/chat-session/session-activity-broadcast.d.ts +30 -0
- package/dist-server/service/chat-session/session-activity-broadcast.js +34 -0
- package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -0
- package/dist-server/service/chat-session/session-activity-publish.d.ts +12 -0
- package/dist-server/service/chat-session/session-activity-publish.js +37 -0
- package/dist-server/service/chat-session/session-activity-publish.js.map +1 -0
- package/dist-server/service/chat-session/session-activity-subscription.d.ts +14 -0
- package/dist-server/service/chat-session/session-activity-subscription.js +87 -0
- package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -0
- package/dist-server/service/chat-session/session-anchor.d.ts +26 -0
- package/dist-server/service/chat-session/session-anchor.js +100 -0
- package/dist-server/service/chat-session/session-anchor.js.map +1 -0
- package/dist-server/service/chat-session/session-inbox.d.ts +80 -0
- package/dist-server/service/chat-session/session-inbox.js +133 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +49 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.js +141 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
- package/dist-server/service/chat-session-participant/index.d.ts +4 -0
- package/dist-server/service/chat-session-participant/index.js +10 -0
- package/dist-server/service/chat-session-participant/index.js.map +1 -0
- package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
- package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
- package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
- package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
- package/dist-server/service/chat-session-participant/presence.js +22 -0
- package/dist-server/service/chat-session-participant/presence.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +93 -0
- package/dist-server/service/chat-session-resolver.js +706 -0
- package/dist-server/service/chat-session-resolver.js.map +1 -0
- package/dist-server/service/index.d.ts +24 -0
- package/dist-server/service/index.js +39 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/subscribe-auth.d.ts +14 -0
- package/dist-server/service/subscribe-auth.js +33 -0
- package/dist-server/service/subscribe-auth.js.map +1 -0
- package/dist-server/service/types.d.ts +5 -0
- package/dist-server/service/types.js +12 -0
- package/dist-server/service/types.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/server/index.ts +28 -0
- package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
- package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
- package/server/service/chat-message/chat-message-broadcast.ts +27 -0
- package/server/service/chat-message/chat-message-publish.ts +22 -0
- package/server/service/chat-message/chat-message-subscription.ts +45 -0
- package/server/service/chat-message/chat-message.ts +195 -0
- package/server/service/chat-message/fold-history.test.ts +98 -0
- package/server/service/chat-message/fold-history.ts +77 -0
- package/server/service/chat-message/history-summary.test.ts +127 -0
- package/server/service/chat-message/history-summary.ts +100 -0
- package/server/service/chat-message/index.ts +12 -0
- package/server/service/chat-message/llm-history.test.ts +194 -0
- package/server/service/chat-message/llm-history.ts +163 -0
- package/server/service/chat-session/chat-session.ts +193 -0
- package/server/service/chat-session/index.ts +5 -0
- package/server/service/chat-session/session-activity-broadcast.ts +57 -0
- package/server/service/chat-session/session-activity-publish.ts +44 -0
- package/server/service/chat-session/session-activity-subscription.ts +67 -0
- package/server/service/chat-session/session-anchor.ts +106 -0
- package/server/service/chat-session/session-inbox.test.ts +185 -0
- package/server/service/chat-session/session-inbox.ts +161 -0
- package/server/service/chat-session-participant/chat-session-participant.ts +135 -0
- package/server/service/chat-session-participant/index.ts +6 -0
- package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
- package/server/service/chat-session-participant/invite-notification.ts +48 -0
- package/server/service/chat-session-participant/presence.test.ts +39 -0
- package/server/service/chat-session-participant/presence.ts +21 -0
- package/server/service/chat-session-resolver.ts +644 -0
- package/server/service/index.ts +37 -0
- package/server/service/session-anchor.test.ts +133 -0
- package/server/service/subscribe-auth.test.ts +57 -0
- package/server/service/subscribe-auth.ts +34 -0
- package/server/service/types.ts +15 -0
- package/things-factory.config.js +1 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,3034 @@
|
|
|
1
|
+
var OxBoardAIChat_1;
|
|
2
|
+
import { __decorate, __metadata } from "tslib";
|
|
3
|
+
/**
|
|
4
|
+
* <ox-board-ai-chat> — AI 주도 보드 모델링 채팅 컴포넌트 (Lit).
|
|
5
|
+
*
|
|
6
|
+
* 입력:
|
|
7
|
+
* - sessionId: 영속 ChatSession 식별자 (없으면 ad-hoc 모드, 메시지 영속 안 됨)
|
|
8
|
+
* - currentBoard: 현재 BoardModel JSON (호스트가 동기화)
|
|
9
|
+
* - scopes / knownTypes / categories: 도메인 컨텍스트
|
|
10
|
+
*
|
|
11
|
+
* 출력 (이벤트):
|
|
12
|
+
* - `board-edit-patch` { detail: { patch, summary, confidence, patchId } }
|
|
13
|
+
* 호스트가 받아서 보드 모델에 적용 (applyBoardEditPatch helper).
|
|
14
|
+
* - `chat-followup` { detail: { question } }
|
|
15
|
+
*
|
|
16
|
+
* 모드 전환은 컨테이너 (워크스페이스) 의 책임. 이 컴포넌트는 자체로 풀 채팅 UX.
|
|
17
|
+
*/
|
|
18
|
+
import '@material/web/icon/icon.js';
|
|
19
|
+
import { LitElement, css, html, nothing } from 'lit';
|
|
20
|
+
import { customElement, property, state } from 'lit/decorators.js';
|
|
21
|
+
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
|
22
|
+
import { client } from '@operato/graphql';
|
|
23
|
+
import { auth } from '@things-factory/auth-base/dist-client/index.js';
|
|
24
|
+
import { i18next } from '@operato/i18n';
|
|
25
|
+
import { ScrollbarStyles } from '@operato/styles';
|
|
26
|
+
import gql from 'graphql-tag';
|
|
27
|
+
import { renderMarkdown, injectMentionRefids, injectMentionUserIds, stripMentionRefids } from './markdown.js';
|
|
28
|
+
import { buildChatMutationInput } from './chat-input-builder.js';
|
|
29
|
+
import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js';
|
|
30
|
+
import './mention-popup.js';
|
|
31
|
+
import { buildMentionCandidates, buildCatalogCandidates, buildSlashCandidates, buildUserCandidates, extractMentionContext, filterMentionCandidates, applyMentionSelection, resolveMentionToken } from './mention-popup.js';
|
|
32
|
+
/*
|
|
33
|
+
* 기본 카탈로그·슬래시 목록은 **호스트가 넣는다** (2026-09-10).
|
|
34
|
+
*
|
|
35
|
+
* 여기서 `./default-catalog-entries.js` 를 직접 import 하고 있었다. 그 두 파일은 보드 저작의
|
|
36
|
+
* 목록이고, 그래서 이 대화면이 보드 편집기 없이는 설 수 없었다 — 이 컴포넌트가 중립 패키지로
|
|
37
|
+
* 오지 못한 유일한 이유가 그 두 줄이었다.
|
|
38
|
+
*
|
|
39
|
+
* 등록하는 쪽이 주인이라는 규약을 따른다(`registerToolCategory` 와 같은 모양): `board-ai` 를
|
|
40
|
+
* import 하면 그 패키지가 자기 기본값을 넣고, 안 하면 비어 있다. 보드가 없는 제품에 보드 목록이
|
|
41
|
+
* 뜨지 않는 것이 맞다.
|
|
42
|
+
*/
|
|
43
|
+
import { chatDefaults } from './chat-defaults.js';
|
|
44
|
+
const BOARD_AI_CHAT_MUTATION = gql `
|
|
45
|
+
mutation BoardAIChat($input: BoardAIChatInput!) {
|
|
46
|
+
boardAIChat(input: $input) {
|
|
47
|
+
reply
|
|
48
|
+
patch
|
|
49
|
+
followUp
|
|
50
|
+
clientId
|
|
51
|
+
sessionId
|
|
52
|
+
userMessageId
|
|
53
|
+
assistantMessageId
|
|
54
|
+
patchId
|
|
55
|
+
toolUsages
|
|
56
|
+
actions
|
|
57
|
+
proposals
|
|
58
|
+
groundingWarnings
|
|
59
|
+
historyFolded
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
`;
|
|
63
|
+
const CHAT_MESSAGES_QUERY = gql `
|
|
64
|
+
query ChatMessages($sessionId: String!, $limit: Int, $offset: Int) {
|
|
65
|
+
chatMessages(sessionId: $sessionId, limit: $limit, offset: $offset) {
|
|
66
|
+
id
|
|
67
|
+
role
|
|
68
|
+
content
|
|
69
|
+
relatedPatchId
|
|
70
|
+
toolUsagesJson
|
|
71
|
+
groundingWarningsJson
|
|
72
|
+
createdAt
|
|
73
|
+
creator {
|
|
74
|
+
name
|
|
75
|
+
email
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
`;
|
|
80
|
+
// C4 그룹챗 — 같은 세션의 메시지(사용자/AI/system) 실시간 방송 구독.
|
|
81
|
+
// 자기 발신 에코는 message.id 로 dedup (mutation 응답으로 이미 렌더).
|
|
82
|
+
const CHAT_MESSAGE_SUBSCRIPTION = gql `
|
|
83
|
+
subscription ChatMessageActivity($sessionId: String!) {
|
|
84
|
+
chatMessageActivity(sessionId: $sessionId) {
|
|
85
|
+
id
|
|
86
|
+
role
|
|
87
|
+
content
|
|
88
|
+
relatedPatchId
|
|
89
|
+
toolUsagesJson
|
|
90
|
+
groundingWarningsJson
|
|
91
|
+
createdAt
|
|
92
|
+
creator {
|
|
93
|
+
name
|
|
94
|
+
email
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
`;
|
|
99
|
+
// C4 presence/roster — 세션 참여자 목록 + 마지막 접속(online 판정용).
|
|
100
|
+
const CHAT_PARTICIPANTS_QUERY = gql `
|
|
101
|
+
query ChatSessionParticipants($sessionId: String!) {
|
|
102
|
+
chatSessionParticipants(sessionId: $sessionId) {
|
|
103
|
+
id
|
|
104
|
+
role
|
|
105
|
+
lastSeenAt
|
|
106
|
+
user {
|
|
107
|
+
id
|
|
108
|
+
name
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
const TOUCH_PRESENCE_MUTATION = gql `
|
|
114
|
+
mutation TouchPresence($sessionId: String!) {
|
|
115
|
+
touchPresence(sessionId: $sessionId)
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
// heartbeat 주기(ms) — 서버 PRESENCE_TTL_MS(75s)의 ~1/2.5. 로컬 online 판정도 동일 TTL.
|
|
119
|
+
const PRESENCE_HEARTBEAT_MS = 30_000;
|
|
120
|
+
const PRESENCE_TTL_MS = 75_000;
|
|
121
|
+
/** 이름 → roster chip 이니셜 (한글 1자 / 영문 최대 2자). */
|
|
122
|
+
function initials(name) {
|
|
123
|
+
const n = (name || '?').trim();
|
|
124
|
+
if (!n)
|
|
125
|
+
return '?';
|
|
126
|
+
const parts = n.split(/\s+/);
|
|
127
|
+
if (parts.length >= 2)
|
|
128
|
+
return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
129
|
+
return /[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(0, 1);
|
|
130
|
+
}
|
|
131
|
+
let OxBoardAIChat = class OxBoardAIChat extends LitElement {
|
|
132
|
+
constructor() {
|
|
133
|
+
super(...arguments);
|
|
134
|
+
/**
|
|
135
|
+
* 보드의 모든 ChatSession 리스트 — 다중 세션 활성화 시 탭 UI 로 표시.
|
|
136
|
+
*
|
|
137
|
+
* 빈 배열 / 미지정 → 단일 세션 UX (탭 안 보임). 한 보드 = 한 세션 가정의 기존 호출자
|
|
138
|
+
* 호환. host (board-modeller-page) 가 chatSessionsByBoard query 결과를 그대로 전달.
|
|
139
|
+
*
|
|
140
|
+
* 이 컴포넌트는 list 자체를 fetch 하지 않음 — 영속 모델 / 권한 / 페이지네이션 정책은
|
|
141
|
+
* host 책임. 본 컴포넌트는 표시 + 이벤트 emission 만.
|
|
142
|
+
*/
|
|
143
|
+
this.sessions = [];
|
|
144
|
+
/**
|
|
145
|
+
* 모델러에서 현재 선택된 컴포넌트의 `refid` 목록.
|
|
146
|
+
*
|
|
147
|
+
* refid 는 things-scene 이 모든 컴포넌트에 자동 발급하는 universal numeric handle.
|
|
148
|
+
* 호스트가 매 render 마다 `.selectedRefids=${...}` 로 갱신. AI 한테 selection 정보로
|
|
149
|
+
* 그대로 전달되어 "선택한" / "selected" / "this" 류 지시어 해석에 사용.
|
|
150
|
+
*/
|
|
151
|
+
this.selectedRefids = [];
|
|
152
|
+
/** 코어 보드 편집 도구 사용 여부. 기본 true. 보드를 고칠 이유가 없는 면에서 false. */
|
|
153
|
+
this.boardTools = true;
|
|
154
|
+
/**
|
|
155
|
+
* 라이브 상태를 다루는 대화면인가 — true 면 **첫 턴에 도구 호출을 강제**한다.
|
|
156
|
+
*
|
|
157
|
+
* 접지 가드는 지어낸 식별자를 잡지만 식별자 없는 상황 서술("바쁘게 움직이고 있다")은 잡지 못한다.
|
|
158
|
+
* 그건 도구를 부르지 않고 답한 경우이므로, 조회를 먼저 하게 만들어 원천을 막는다.
|
|
159
|
+
*/
|
|
160
|
+
this.requireGroundingTools = false;
|
|
161
|
+
this.placeholder = '자연어로 명령하세요 (Shift+Enter 줄바꿈, Enter 전송)';
|
|
162
|
+
this.lines = [];
|
|
163
|
+
this.input = '';
|
|
164
|
+
/** 되돌려진 patch id 들 — 한 번 revert 한 patch 는 버튼 비활성 */
|
|
165
|
+
this.revertedPatchIds = new Set();
|
|
166
|
+
/** mini action — 인라인 예시 토글 */
|
|
167
|
+
this.examplesOpen = false;
|
|
168
|
+
/** 원본 JSON 을 펼친 단계(라인:단계) — 목록은 한 줄 요약으로 훑고 필요한 것만 펼친다. */
|
|
169
|
+
this.openToolSteps = new Set();
|
|
170
|
+
/** 이미 실행을 요청한 제안 키 — 같은 카드를 두 번 눌러 되돌릴 수 없는 명령이 두 번 나가지 않게. */
|
|
171
|
+
this.sentProposals = new Set();
|
|
172
|
+
/** 안내를 닫았는가 — 한 번 닫으면 이 세션에서는 다시 띄우지 않는다(잔소리 금지). */
|
|
173
|
+
this.foldNoticeDismissed = false;
|
|
174
|
+
this.busy = false;
|
|
175
|
+
/** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
|
|
176
|
+
this.participants = [];
|
|
177
|
+
/** roster online 재평가 트리거 — heartbeat tick 마다 갱신(시간 경과 반영). */
|
|
178
|
+
this._presenceTick = 0;
|
|
179
|
+
/** 이미 transcript 에 반영된 서버 메시지 id 집합 — 자기 에코·중복 방송 차단. */
|
|
180
|
+
this._seenMessageIds = new Set();
|
|
181
|
+
/** optimistic 라인 임시 핸들 시퀀스 (index 비의존 reconcile 용). */
|
|
182
|
+
this._localSeq = 0;
|
|
183
|
+
/** Mention popup — `#` (컴포넌트) / `@` (카탈로그) / `/` (slash) 트리거 시 활성.
|
|
184
|
+
* 단일 popup 재사용 — mentionTrigger 로 활성 trigger 추적. */
|
|
185
|
+
this.mentionOpen = false;
|
|
186
|
+
/** popup 표시용 — F-Finder 처럼 matchType + matchValue 동봉된 결과 */
|
|
187
|
+
this.mentionResults = [];
|
|
188
|
+
this.mentionActiveIndex = 0;
|
|
189
|
+
/** filter cap (50) 으로 잘라낸 추가 매칭 수 — popup 의 'N more' 표시용 */
|
|
190
|
+
this.mentionOverflow = 0;
|
|
191
|
+
/** 현재 활성 mention 의 textarea 내 trigger 위치 */
|
|
192
|
+
this.mentionHashPos = -1;
|
|
193
|
+
/** 현재 활성 trigger char — '#' / '@' / '/'. popup 비활성 시 의미 없음. */
|
|
194
|
+
this.mentionTrigger = '#';
|
|
195
|
+
/** popup 열기 직전 사용자 selection 스냅샷 — Esc/blur close 시 원복용 */
|
|
196
|
+
this.originalSelectedRefids = [];
|
|
197
|
+
/** popup 에서 명시 선택된 #mention 매핑 — {token: refid}.
|
|
198
|
+
* 사용자 textarea 에는 #token 만 보이지만 server 한테는 정확한 refid 전달.
|
|
199
|
+
* send 시 mutation input.mentions 로 함께 전송. 사용자가 textarea 비우거나
|
|
200
|
+
* 새 채팅 시작하면 정리. */
|
|
201
|
+
this.pickedMentions = new Map();
|
|
202
|
+
/** popup 에서 명시 선택된 @user 멘션 매핑 — {token: userId}.
|
|
203
|
+
* textarea 에는 @username 만 보이지만 server 한테는 정확한 userId 전달.
|
|
204
|
+
* send 시 mutation input.userMentions 로 함께 전송. */
|
|
205
|
+
this.pickedUserMentions = new Map();
|
|
206
|
+
/** Default 카탈로그 후보 캐시 — chat panel mount 시 1회 빌드 */
|
|
207
|
+
this.cachedCatalogCandidates = buildCatalogCandidates(chatDefaults.catalogEntries);
|
|
208
|
+
/** Default slash template 후보 캐시 — chat panel mount 시 1회 빌드 */
|
|
209
|
+
this.cachedSlashCandidates = buildSlashCandidates(chatDefaults.slashTemplates);
|
|
210
|
+
/** `@` 마지막 query 의 도메인 사용자 후보 캐시 — userProvider 응답 도착 시 갱신.
|
|
211
|
+
* query 별 캐시가 아니라 단순 "최근 결과" — async race 회피. */
|
|
212
|
+
this.cachedUserCandidates = [];
|
|
213
|
+
/** 진행 중인 userProvider fetch token — 늦게 도착한 응답이 최신을 덮어쓰지 않도록 식별. */
|
|
214
|
+
this.userFetchToken = 0;
|
|
215
|
+
/** 메시지 버블의 `<span class="mention">#token</span>` 클릭 — 컴포넌트로 select + centerTo.
|
|
216
|
+
* resolve 룰은 pure helper `resolveMentionToken` 에 위임 (단위 테스트 가능). */
|
|
217
|
+
this.onBubbleClick = (e) => {
|
|
218
|
+
const target = e.target;
|
|
219
|
+
if (!target?.classList?.contains('mention'))
|
|
220
|
+
return;
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
const text = (target.textContent || '').trim();
|
|
223
|
+
const token = text.startsWith('#') ? text.slice(1) : text;
|
|
224
|
+
if (!token)
|
|
225
|
+
return;
|
|
226
|
+
const refid = resolveMentionToken(token, {
|
|
227
|
+
persistedRefid: target.getAttribute('data-refid'),
|
|
228
|
+
searchProvider: this.searchProvider,
|
|
229
|
+
board: this.boardProvider ? this.boardProvider() : this.currentBoard
|
|
230
|
+
});
|
|
231
|
+
if (typeof refid === 'number') {
|
|
232
|
+
this.dispatchActions([
|
|
233
|
+
{ action: 'selectComponents', refids: [refid] },
|
|
234
|
+
{ action: 'centerToComponent', refid, animated: true }
|
|
235
|
+
]);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
this.showToast(i18next.t('board-ai.text.mention-not-found', {
|
|
239
|
+
token,
|
|
240
|
+
defaultValue: `#${token} 을(를) 찾을 수 없습니다`
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
this._onSessionCreateClick = () => {
|
|
245
|
+
this.dispatchEvent(new CustomEvent('session-create', {
|
|
246
|
+
bubbles: true,
|
|
247
|
+
composed: true
|
|
248
|
+
}));
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* 새 대화로 — **맥락을 옮기지 않는다.** 주제를 바꾸려고 새로 시작하는 것이므로, 옮기면 그 이유가
|
|
252
|
+
* 사라진다(승격은 이어가려는 것이고 이건 끊으려는 것이다). 세션 생성은 호스트의 몫이다.
|
|
253
|
+
*/
|
|
254
|
+
this.startNewFromNotice = () => {
|
|
255
|
+
this.foldNoticeDismissed = true;
|
|
256
|
+
this.dispatchEvent(new CustomEvent('board-ai-start-new-session', { bubbles: true, composed: true }));
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
static { OxBoardAIChat_1 = this; }
|
|
260
|
+
/** 현재 로그인 사용자 email — 그룹챗에서 내 메시지 vs 타인 메시지 구분.
|
|
261
|
+
* email 을 쓰는 이유: /auth/profile 이 항상 내려주는 로그인 식별자이고(비어 있지 않다),
|
|
262
|
+
* 내부 관리키(User.id)를 클라이언트 판정에 의존하지 않기 위함. */
|
|
263
|
+
get _myEmail() {
|
|
264
|
+
return auth.credential?.email;
|
|
265
|
+
}
|
|
266
|
+
static { this.styles = [
|
|
267
|
+
// 어플리케이션 표준 스크롤바 (--scrollbar-width / --scrollbar-thumb-color 등 호스트 변수 따름)
|
|
268
|
+
ScrollbarStyles,
|
|
269
|
+
css `
|
|
270
|
+
/*
|
|
271
|
+
Material Design 3 토큰 사용 — 사용자 테마 (라이트/다크/브랜드) 자동 반영.
|
|
272
|
+
각 var() 의 두 번째 인자는 토큰 미정의 시 fallback (현 디자인 톤).
|
|
273
|
+
*/
|
|
274
|
+
:host {
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-direction: column;
|
|
277
|
+
height: 100%;
|
|
278
|
+
min-height: 240px;
|
|
279
|
+
position: relative;
|
|
280
|
+
font:
|
|
281
|
+
400 11px / 1.55 -apple-system,
|
|
282
|
+
BlinkMacSystemFont,
|
|
283
|
+
'Inter',
|
|
284
|
+
'Pretendard',
|
|
285
|
+
'Segoe UI',
|
|
286
|
+
Roboto,
|
|
287
|
+
sans-serif;
|
|
288
|
+
letter-spacing: -0.005em;
|
|
289
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
290
|
+
background: var(--md-sys-color-surface, #ffffff);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ── C4 Roster (participants + presence) ───────────── */
|
|
294
|
+
.roster {
|
|
295
|
+
display: flex;
|
|
296
|
+
align-items: center;
|
|
297
|
+
flex-wrap: wrap;
|
|
298
|
+
gap: 4px;
|
|
299
|
+
padding: 4px 8px;
|
|
300
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
301
|
+
}
|
|
302
|
+
.roster-count {
|
|
303
|
+
display: inline-flex;
|
|
304
|
+
align-items: center;
|
|
305
|
+
gap: 2px;
|
|
306
|
+
font-size: 10px;
|
|
307
|
+
opacity: 0.7;
|
|
308
|
+
margin-right: 2px;
|
|
309
|
+
}
|
|
310
|
+
.roster-count md-icon {
|
|
311
|
+
font-size: 13px;
|
|
312
|
+
width: 13px;
|
|
313
|
+
height: 13px;
|
|
314
|
+
}
|
|
315
|
+
.roster-chip {
|
|
316
|
+
display: inline-flex;
|
|
317
|
+
align-items: center;
|
|
318
|
+
gap: 3px;
|
|
319
|
+
padding: 1px 6px 1px 4px;
|
|
320
|
+
border-radius: 9px;
|
|
321
|
+
font-size: 10px;
|
|
322
|
+
background: var(--md-sys-color-surface-variant, #f1f5f9);
|
|
323
|
+
}
|
|
324
|
+
.roster-chip.offline {
|
|
325
|
+
opacity: 0.45;
|
|
326
|
+
}
|
|
327
|
+
.roster-dot {
|
|
328
|
+
width: 6px;
|
|
329
|
+
height: 6px;
|
|
330
|
+
border-radius: 50%;
|
|
331
|
+
background: #cbd5e1;
|
|
332
|
+
}
|
|
333
|
+
.roster-chip.online .roster-dot {
|
|
334
|
+
background: #22c55e;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* ── Session tabs ─────────────────────────────────── */
|
|
338
|
+
.session-tabs {
|
|
339
|
+
display: flex;
|
|
340
|
+
align-items: center;
|
|
341
|
+
gap: 2px;
|
|
342
|
+
padding: 4px 6px 0 6px;
|
|
343
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
344
|
+
background: var(--md-sys-color-surface-container-low, #fafbfc);
|
|
345
|
+
overflow-x: auto;
|
|
346
|
+
scrollbar-width: thin;
|
|
347
|
+
flex-shrink: 0;
|
|
348
|
+
}
|
|
349
|
+
.session-tab {
|
|
350
|
+
flex-shrink: 0;
|
|
351
|
+
padding: 4px 10px;
|
|
352
|
+
font-size: 11px;
|
|
353
|
+
line-height: 1.4;
|
|
354
|
+
color: var(--md-sys-color-on-surface-variant, #64748b);
|
|
355
|
+
background: transparent;
|
|
356
|
+
border: 1px solid transparent;
|
|
357
|
+
border-bottom: 0;
|
|
358
|
+
border-top-left-radius: 5px;
|
|
359
|
+
border-top-right-radius: 5px;
|
|
360
|
+
cursor: pointer;
|
|
361
|
+
max-width: 160px;
|
|
362
|
+
white-space: nowrap;
|
|
363
|
+
overflow: hidden;
|
|
364
|
+
text-overflow: ellipsis;
|
|
365
|
+
}
|
|
366
|
+
.session-tab:hover {
|
|
367
|
+
background: var(--md-sys-color-surface-container, #f1f5f9);
|
|
368
|
+
}
|
|
369
|
+
.session-tab.active {
|
|
370
|
+
background: var(--md-sys-color-surface, #ffffff);
|
|
371
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
372
|
+
border-color: var(--md-sys-color-outline-variant, #e2e8f0);
|
|
373
|
+
font-weight: 500;
|
|
374
|
+
position: relative;
|
|
375
|
+
}
|
|
376
|
+
.session-tab.active::after {
|
|
377
|
+
content: '';
|
|
378
|
+
position: absolute;
|
|
379
|
+
left: 0;
|
|
380
|
+
right: 0;
|
|
381
|
+
bottom: -1px;
|
|
382
|
+
height: 1px;
|
|
383
|
+
background: var(--md-sys-color-surface, #ffffff);
|
|
384
|
+
}
|
|
385
|
+
.session-tab-new {
|
|
386
|
+
flex-shrink: 0;
|
|
387
|
+
padding: 4px 8px 4px 6px;
|
|
388
|
+
background: transparent;
|
|
389
|
+
border: 0;
|
|
390
|
+
cursor: pointer;
|
|
391
|
+
color: var(--md-sys-color-on-surface-variant, #64748b);
|
|
392
|
+
display: inline-flex;
|
|
393
|
+
align-items: center;
|
|
394
|
+
gap: 2px;
|
|
395
|
+
font-size: 11px;
|
|
396
|
+
line-height: 1.4;
|
|
397
|
+
border-radius: 5px;
|
|
398
|
+
white-space: nowrap;
|
|
399
|
+
}
|
|
400
|
+
.session-tab-new:hover {
|
|
401
|
+
color: var(--md-sys-color-primary, #2563eb);
|
|
402
|
+
background: var(--md-sys-color-surface-container, #f1f5f9);
|
|
403
|
+
}
|
|
404
|
+
.session-tab-new md-icon {
|
|
405
|
+
--md-icon-size: 14px;
|
|
406
|
+
font-size: 14px;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/* ── Messages ─────────────────────────────────────── */
|
|
410
|
+
/* 스크롤바는 ScrollbarStyles (위쪽 styles 배열) 가 처리 — 어플리케이션 표준 따름 */
|
|
411
|
+
.messages {
|
|
412
|
+
flex: 1;
|
|
413
|
+
overflow-y: auto;
|
|
414
|
+
overflow-x: hidden; /* 가로 스크롤 차단 — 자식 (empty / msg) 내부에서 wrap 처리 */
|
|
415
|
+
padding: 10px 16px 4px;
|
|
416
|
+
display: flex;
|
|
417
|
+
flex-direction: column;
|
|
418
|
+
gap: 4px; /* 단일 spacing source — .msg-wrap 의 자체 margin 은 0 */
|
|
419
|
+
scroll-behavior: smooth;
|
|
420
|
+
min-width: 0; /* flex item 의 기본 min-content 무시 — 자식 ellipsis/wrap 동작 */
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* ── Empty state — onboarding 가이드 ───────────────── */
|
|
424
|
+
.empty {
|
|
425
|
+
margin: auto;
|
|
426
|
+
text-align: left;
|
|
427
|
+
width: 100%;
|
|
428
|
+
max-width: 360px;
|
|
429
|
+
box-sizing: border-box;
|
|
430
|
+
min-width: 0;
|
|
431
|
+
display: flex;
|
|
432
|
+
flex-direction: column;
|
|
433
|
+
gap: 16px;
|
|
434
|
+
word-break: break-word; /* 긴 한글 / URL 도 줄바꿈해 가로 overflow 방지 */
|
|
435
|
+
overflow-wrap: anywhere;
|
|
436
|
+
}
|
|
437
|
+
.empty .header {
|
|
438
|
+
text-align: center;
|
|
439
|
+
padding: 8px 0 4px;
|
|
440
|
+
}
|
|
441
|
+
.empty .icon {
|
|
442
|
+
font-size: 22px;
|
|
443
|
+
opacity: 0.4;
|
|
444
|
+
margin-bottom: 8px;
|
|
445
|
+
color: var(--md-sys-color-primary, currentColor);
|
|
446
|
+
display: block;
|
|
447
|
+
}
|
|
448
|
+
.empty .title {
|
|
449
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
450
|
+
font-size: 12.5px;
|
|
451
|
+
font-weight: 600;
|
|
452
|
+
margin-bottom: 4px;
|
|
453
|
+
letter-spacing: -0.01em;
|
|
454
|
+
}
|
|
455
|
+
.empty .subtitle {
|
|
456
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
457
|
+
font-size: 10.5px;
|
|
458
|
+
line-height: 1.5;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.empty .group {
|
|
462
|
+
display: flex;
|
|
463
|
+
flex-direction: column;
|
|
464
|
+
gap: 4px;
|
|
465
|
+
}
|
|
466
|
+
.empty .group-label {
|
|
467
|
+
font-size: 9.5px;
|
|
468
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
469
|
+
letter-spacing: 0.06em;
|
|
470
|
+
text-transform: uppercase;
|
|
471
|
+
font-weight: 600;
|
|
472
|
+
padding: 0 2px;
|
|
473
|
+
}
|
|
474
|
+
.empty .example {
|
|
475
|
+
all: unset;
|
|
476
|
+
display: block;
|
|
477
|
+
cursor: pointer;
|
|
478
|
+
padding: 7px 10px;
|
|
479
|
+
font-size: 11px;
|
|
480
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
481
|
+
background: var(--md-sys-color-surface-container, #f8fafc);
|
|
482
|
+
border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
|
|
483
|
+
border-radius: 8px;
|
|
484
|
+
line-height: 1.5;
|
|
485
|
+
box-sizing: border-box;
|
|
486
|
+
width: 100%;
|
|
487
|
+
max-width: 100%;
|
|
488
|
+
word-break: break-word;
|
|
489
|
+
overflow-wrap: anywhere;
|
|
490
|
+
transition:
|
|
491
|
+
background 0.15s,
|
|
492
|
+
border-color 0.15s,
|
|
493
|
+
transform 0.1s;
|
|
494
|
+
}
|
|
495
|
+
.empty .example:hover {
|
|
496
|
+
background: var(--md-sys-color-surface-container-high, #f1f5f9);
|
|
497
|
+
border-color: var(--md-sys-color-outline, #cbd5e1);
|
|
498
|
+
}
|
|
499
|
+
.empty .example:active {
|
|
500
|
+
transform: scale(0.99);
|
|
501
|
+
}
|
|
502
|
+
.empty .example .arrow {
|
|
503
|
+
float: right;
|
|
504
|
+
opacity: 0.4;
|
|
505
|
+
margin-left: 8px;
|
|
506
|
+
font-size: 11px;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.empty .footer {
|
|
510
|
+
margin-top: 4px;
|
|
511
|
+
padding: 8px 4px 0;
|
|
512
|
+
border-top: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
|
|
513
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
514
|
+
font-size: 10px;
|
|
515
|
+
line-height: 1.6;
|
|
516
|
+
text-align: center;
|
|
517
|
+
}
|
|
518
|
+
.empty .footer .badge {
|
|
519
|
+
display: inline-block;
|
|
520
|
+
margin: 0 4px;
|
|
521
|
+
padding: 1px 7px;
|
|
522
|
+
background: var(--md-sys-color-surface-container, #f8fafc);
|
|
523
|
+
border-radius: 8px;
|
|
524
|
+
font-size: 9.5px;
|
|
525
|
+
color: var(--md-sys-color-on-surface-variant, #64748b);
|
|
526
|
+
}
|
|
527
|
+
/* ── Trigger chip row (empty state) ──────────────── */
|
|
528
|
+
.empty .trigger-row {
|
|
529
|
+
margin: 12px 0 4px;
|
|
530
|
+
padding: 10px 12px;
|
|
531
|
+
background: var(--md-sys-color-surface-container-low, #f8fafc);
|
|
532
|
+
border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
|
|
533
|
+
border-radius: 10px;
|
|
534
|
+
}
|
|
535
|
+
.empty .trigger-row-label {
|
|
536
|
+
font-size: 10px;
|
|
537
|
+
color: var(--md-sys-color-on-surface-variant, #64748b);
|
|
538
|
+
margin-bottom: 6px;
|
|
539
|
+
text-align: center;
|
|
540
|
+
letter-spacing: 0.02em;
|
|
541
|
+
}
|
|
542
|
+
.empty .trigger-chips {
|
|
543
|
+
display: flex;
|
|
544
|
+
gap: 6px;
|
|
545
|
+
justify-content: center;
|
|
546
|
+
flex-wrap: wrap;
|
|
547
|
+
}
|
|
548
|
+
.empty .trigger-chip {
|
|
549
|
+
display: inline-flex;
|
|
550
|
+
align-items: center;
|
|
551
|
+
gap: 5px;
|
|
552
|
+
padding: 5px 10px;
|
|
553
|
+
background: var(--md-sys-color-surface, #ffffff);
|
|
554
|
+
border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
555
|
+
border-radius: 18px;
|
|
556
|
+
cursor: pointer;
|
|
557
|
+
font-size: 11.5px;
|
|
558
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
559
|
+
transition:
|
|
560
|
+
border-color 0.15s,
|
|
561
|
+
background 0.15s,
|
|
562
|
+
color 0.15s;
|
|
563
|
+
}
|
|
564
|
+
.empty .trigger-chip:hover {
|
|
565
|
+
border-color: var(--md-sys-color-primary, #2563eb);
|
|
566
|
+
background: var(--md-sys-color-primary-container, #dbeafe);
|
|
567
|
+
color: var(--md-sys-color-on-primary-container, #1e3a8a);
|
|
568
|
+
}
|
|
569
|
+
.empty .trigger-chip .trigger-char {
|
|
570
|
+
font-family:
|
|
571
|
+
'SF Mono',
|
|
572
|
+
Menlo,
|
|
573
|
+
Monaco,
|
|
574
|
+
Consolas,
|
|
575
|
+
monospace;
|
|
576
|
+
font-size: 13px;
|
|
577
|
+
font-weight: 600;
|
|
578
|
+
color: var(--md-sys-color-primary, #2563eb);
|
|
579
|
+
min-width: 12px;
|
|
580
|
+
text-align: center;
|
|
581
|
+
}
|
|
582
|
+
.empty .trigger-chip:hover .trigger-char {
|
|
583
|
+
color: var(--md-sys-color-on-primary-container, #1e3a8a);
|
|
584
|
+
}
|
|
585
|
+
.empty .trigger-chip .trigger-desc {
|
|
586
|
+
font-weight: 500;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/* ── Message label (sender hint) ──────────────────── */
|
|
590
|
+
.msg-label {
|
|
591
|
+
font-size: 9.5px;
|
|
592
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
593
|
+
letter-spacing: 0.05em;
|
|
594
|
+
font-weight: 500;
|
|
595
|
+
text-transform: uppercase;
|
|
596
|
+
margin: 4px 0 1px 4px;
|
|
597
|
+
align-self: flex-start;
|
|
598
|
+
}
|
|
599
|
+
.msg-label:first-child {
|
|
600
|
+
margin-top: 0;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* ── Message wrapper (메시지 + hover 액션) ────────── */
|
|
604
|
+
.msg-wrap {
|
|
605
|
+
display: flex;
|
|
606
|
+
flex-direction: column;
|
|
607
|
+
width: fit-content;
|
|
608
|
+
max-width: min(92%, 420px);
|
|
609
|
+
/* margin 0 — spacing 은 .messages gap 단일 source */
|
|
610
|
+
}
|
|
611
|
+
.msg-wrap.user {
|
|
612
|
+
align-self: flex-end;
|
|
613
|
+
align-items: flex-end;
|
|
614
|
+
}
|
|
615
|
+
.msg-wrap.assistant {
|
|
616
|
+
align-self: flex-start;
|
|
617
|
+
align-items: flex-start;
|
|
618
|
+
}
|
|
619
|
+
.msg-wrap.system {
|
|
620
|
+
align-self: center;
|
|
621
|
+
align-items: center;
|
|
622
|
+
max-width: 100%;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/* ── Message bubble ───────────────────────────────── */
|
|
626
|
+
.msg {
|
|
627
|
+
display: inline-block;
|
|
628
|
+
width: fit-content;
|
|
629
|
+
max-width: 100%;
|
|
630
|
+
padding: 5px 9px;
|
|
631
|
+
border-radius: 10px;
|
|
632
|
+
white-space: normal;
|
|
633
|
+
overflow-wrap: anywhere;
|
|
634
|
+
font-size: 11px;
|
|
635
|
+
line-height: 1.45;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.msg.user {
|
|
639
|
+
align-self: flex-end;
|
|
640
|
+
background: var(--md-sys-color-primary, #1e293b);
|
|
641
|
+
color: var(--md-sys-color-on-primary, #f8fafc);
|
|
642
|
+
border-radius: 12px 12px 3px 12px;
|
|
643
|
+
/* margin-top 0 — .messages gap 으로만 spacing */
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/* 그룹챗 — 다른 참여자의 메시지. 내 메시지(우측 primary)와 구분: 좌측 정렬 + 보조 색. */
|
|
647
|
+
.msg-wrap.user.other {
|
|
648
|
+
align-self: flex-start;
|
|
649
|
+
align-items: flex-start;
|
|
650
|
+
}
|
|
651
|
+
.msg.user.other {
|
|
652
|
+
align-self: flex-start;
|
|
653
|
+
background: var(--md-sys-color-secondary-container, #e2e8f0);
|
|
654
|
+
color: var(--md-sys-color-on-secondary-container, #0f172a);
|
|
655
|
+
border-radius: 12px 12px 12px 3px;
|
|
656
|
+
}
|
|
657
|
+
/* 발신자 이름 라벨 — AI 라벨(대문자)과 달리 사람 이름은 그대로. */
|
|
658
|
+
.msg-label.sender {
|
|
659
|
+
text-transform: none;
|
|
660
|
+
letter-spacing: 0;
|
|
661
|
+
color: var(--md-sys-color-primary, #475569);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.msg.assistant {
|
|
665
|
+
align-self: flex-start;
|
|
666
|
+
background: var(--md-sys-color-surface-container, #f8fafc);
|
|
667
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
668
|
+
border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
|
|
669
|
+
border-radius: 12px 12px 12px 3px;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* ── Markdown 렌더 (AI 응답) ──────────────────────── */
|
|
673
|
+
.md-body {
|
|
674
|
+
display: block;
|
|
675
|
+
}
|
|
676
|
+
.md-body > :first-child {
|
|
677
|
+
margin-top: 0;
|
|
678
|
+
}
|
|
679
|
+
.md-body > :last-child {
|
|
680
|
+
margin-bottom: 0;
|
|
681
|
+
}
|
|
682
|
+
.md-body p {
|
|
683
|
+
margin: 0 0 4px;
|
|
684
|
+
line-height: 1.45;
|
|
685
|
+
}
|
|
686
|
+
.md-body ul,
|
|
687
|
+
.md-body ol {
|
|
688
|
+
margin: 2px 0 4px;
|
|
689
|
+
padding-left: 16px;
|
|
690
|
+
}
|
|
691
|
+
.md-body li {
|
|
692
|
+
margin: 0;
|
|
693
|
+
line-height: 1.45;
|
|
694
|
+
}
|
|
695
|
+
.md-body li > p {
|
|
696
|
+
margin: 0;
|
|
697
|
+
}
|
|
698
|
+
.md-body strong {
|
|
699
|
+
font-weight: 600;
|
|
700
|
+
}
|
|
701
|
+
.md-body em {
|
|
702
|
+
font-style: italic;
|
|
703
|
+
}
|
|
704
|
+
/* '#token' mention — popup 의 match-badge 톤과 맞춤. 채팅 버블에서 컴포넌트
|
|
705
|
+
참조임을 시각적으로 구분 (사용자/AI 메시지 양쪽). */
|
|
706
|
+
.md-body .mention {
|
|
707
|
+
display: inline-block;
|
|
708
|
+
padding: 0 5px;
|
|
709
|
+
border-radius: 4px;
|
|
710
|
+
background: var(--md-sys-color-primary-container, rgba(37, 99, 235, 0.12));
|
|
711
|
+
color: var(--md-sys-color-on-primary-container, #1e40af);
|
|
712
|
+
font-weight: 500;
|
|
713
|
+
white-space: nowrap;
|
|
714
|
+
cursor: pointer;
|
|
715
|
+
transition: background 0.12s;
|
|
716
|
+
}
|
|
717
|
+
.md-body .mention:hover {
|
|
718
|
+
background: var(--md-sys-color-primary, rgba(37, 99, 235, 0.25));
|
|
719
|
+
color: var(--md-sys-color-on-primary, #ffffff);
|
|
720
|
+
}
|
|
721
|
+
.msg.user .md-body .mention {
|
|
722
|
+
background: rgba(255, 255, 255, 0.22);
|
|
723
|
+
color: inherit;
|
|
724
|
+
}
|
|
725
|
+
.msg.user .md-body .mention:hover {
|
|
726
|
+
background: rgba(255, 255, 255, 0.40);
|
|
727
|
+
}
|
|
728
|
+
.md-body code {
|
|
729
|
+
font-family:
|
|
730
|
+
'SF Mono',
|
|
731
|
+
Menlo,
|
|
732
|
+
Monaco,
|
|
733
|
+
Consolas,
|
|
734
|
+
monospace;
|
|
735
|
+
font-size: 10px;
|
|
736
|
+
padding: 1px 4px;
|
|
737
|
+
background: var(--md-sys-color-surface-container-high, rgba(15, 23, 42, 0.06));
|
|
738
|
+
border-radius: 3px;
|
|
739
|
+
}
|
|
740
|
+
.md-body pre {
|
|
741
|
+
margin: 5px 0;
|
|
742
|
+
padding: 6px 8px;
|
|
743
|
+
background: var(--md-sys-color-surface-container-high, rgba(15, 23, 42, 0.06));
|
|
744
|
+
border-radius: 5px;
|
|
745
|
+
overflow-x: auto;
|
|
746
|
+
font-size: 10px;
|
|
747
|
+
line-height: 1.5;
|
|
748
|
+
}
|
|
749
|
+
/* user 메시지(어두운 배경) 위에서는 code/pre 의 contrast 보정 */
|
|
750
|
+
.msg.user .md-body code,
|
|
751
|
+
.msg.user .md-body pre {
|
|
752
|
+
background: rgba(255, 255, 255, 0.15);
|
|
753
|
+
}
|
|
754
|
+
.msg.user .md-body a {
|
|
755
|
+
color: inherit;
|
|
756
|
+
text-decoration: underline;
|
|
757
|
+
text-underline-offset: 2px;
|
|
758
|
+
}
|
|
759
|
+
.msg.user .md-body blockquote {
|
|
760
|
+
border-left-color: rgba(255, 255, 255, 0.3);
|
|
761
|
+
color: rgba(255, 255, 255, 0.85);
|
|
762
|
+
}
|
|
763
|
+
.msg.user .md-body hr {
|
|
764
|
+
border-top-color: rgba(255, 255, 255, 0.2);
|
|
765
|
+
}
|
|
766
|
+
.md-body pre code {
|
|
767
|
+
padding: 0;
|
|
768
|
+
background: transparent;
|
|
769
|
+
font-size: inherit;
|
|
770
|
+
}
|
|
771
|
+
.md-body a {
|
|
772
|
+
color: var(--md-sys-color-primary, #1e293b);
|
|
773
|
+
text-decoration: underline;
|
|
774
|
+
text-underline-offset: 2px;
|
|
775
|
+
}
|
|
776
|
+
.md-body a:hover {
|
|
777
|
+
text-decoration: none;
|
|
778
|
+
}
|
|
779
|
+
.md-body h1,
|
|
780
|
+
.md-body h2,
|
|
781
|
+
.md-body h3,
|
|
782
|
+
.md-body h4 {
|
|
783
|
+
margin: 4px 0 2px;
|
|
784
|
+
font-weight: 600;
|
|
785
|
+
line-height: 1.35;
|
|
786
|
+
}
|
|
787
|
+
.md-body h1 { font-size: 13px; }
|
|
788
|
+
.md-body h2 { font-size: 12px; }
|
|
789
|
+
.md-body h3,
|
|
790
|
+
.md-body h4 { font-size: 11.5px; }
|
|
791
|
+
.md-body blockquote {
|
|
792
|
+
margin: 4px 0;
|
|
793
|
+
padding: 1px 8px;
|
|
794
|
+
border-left: 2px solid var(--md-sys-color-outline-variant, #cbd5e1);
|
|
795
|
+
color: var(--md-sys-color-on-surface-variant, #64748b);
|
|
796
|
+
}
|
|
797
|
+
.md-body hr {
|
|
798
|
+
border: 0;
|
|
799
|
+
border-top: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
800
|
+
margin: 6px 0;
|
|
801
|
+
}
|
|
802
|
+
.md-body table {
|
|
803
|
+
border-collapse: collapse;
|
|
804
|
+
margin: 4px 0;
|
|
805
|
+
font-size: 10px;
|
|
806
|
+
}
|
|
807
|
+
.md-body th,
|
|
808
|
+
.md-body td {
|
|
809
|
+
border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
810
|
+
padding: 2px 6px;
|
|
811
|
+
}
|
|
812
|
+
.md-body th {
|
|
813
|
+
background: var(--md-sys-color-surface-container, #f8fafc);
|
|
814
|
+
font-weight: 600;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
.msg.system {
|
|
818
|
+
align-self: center;
|
|
819
|
+
background: transparent;
|
|
820
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
821
|
+
font-size: 10px;
|
|
822
|
+
font-style: italic;
|
|
823
|
+
max-width: 100%;
|
|
824
|
+
padding: 2px 8px;
|
|
825
|
+
margin: 0;
|
|
826
|
+
border: 0;
|
|
827
|
+
}
|
|
828
|
+
/* 시스템 기록은 **곁줄**이다 — 길면 화면을 잡아먹어 대화가 안 보인다(옮겨온 대화를 한 덩어리로
|
|
829
|
+
넣었을 때 실제로 그랬다). 세 줄로 접고 눌러서 펼친다. */
|
|
830
|
+
.msg.system .sys-text {
|
|
831
|
+
display: -webkit-box;
|
|
832
|
+
-webkit-box-orient: vertical;
|
|
833
|
+
-webkit-line-clamp: 3;
|
|
834
|
+
overflow: hidden;
|
|
835
|
+
white-space: pre-wrap;
|
|
836
|
+
cursor: pointer;
|
|
837
|
+
}
|
|
838
|
+
.msg.system.open .sys-text {
|
|
839
|
+
display: block;
|
|
840
|
+
-webkit-line-clamp: unset;
|
|
841
|
+
overflow: visible;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
.msg.pending {
|
|
845
|
+
opacity: 0.5;
|
|
846
|
+
}
|
|
847
|
+
.typing-dots {
|
|
848
|
+
display: inline-flex;
|
|
849
|
+
gap: 4px;
|
|
850
|
+
align-items: center;
|
|
851
|
+
vertical-align: middle;
|
|
852
|
+
}
|
|
853
|
+
.typing-dots > span {
|
|
854
|
+
display: inline-block;
|
|
855
|
+
width: 6px;
|
|
856
|
+
height: 6px;
|
|
857
|
+
border-radius: 50%;
|
|
858
|
+
background: currentColor;
|
|
859
|
+
opacity: 0.4;
|
|
860
|
+
animation: pulse 1.2s ease-in-out infinite;
|
|
861
|
+
}
|
|
862
|
+
.typing-dots > span:nth-child(2) { animation-delay: 0.2s; }
|
|
863
|
+
.typing-dots > span:nth-child(3) { animation-delay: 0.4s; }
|
|
864
|
+
@keyframes pulse {
|
|
865
|
+
0%, 100% { opacity: 0.2; transform: scale(0.85); }
|
|
866
|
+
50% { opacity: 0.7; transform: scale(1.1); }
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/* ── Patch indicator + 인라인 되돌리기 ─────────────── */
|
|
870
|
+
.summary {
|
|
871
|
+
display: inline-flex;
|
|
872
|
+
align-items: center;
|
|
873
|
+
gap: 6px;
|
|
874
|
+
margin-top: 4px;
|
|
875
|
+
padding: 3px 4px 3px 8px;
|
|
876
|
+
background: var(--md-sys-color-surface-container-high, #f1f5f9);
|
|
877
|
+
border-radius: 6px;
|
|
878
|
+
font-size: 10px;
|
|
879
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
880
|
+
line-height: 1;
|
|
881
|
+
}
|
|
882
|
+
.summary md-icon {
|
|
883
|
+
--md-icon-size: 13px;
|
|
884
|
+
color: var(--md-sys-color-tertiary, #10b981);
|
|
885
|
+
}
|
|
886
|
+
.summary.reverted {
|
|
887
|
+
opacity: 0.6;
|
|
888
|
+
}
|
|
889
|
+
.summary.reverted md-icon {
|
|
890
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
891
|
+
}
|
|
892
|
+
.summary .summary-text {
|
|
893
|
+
letter-spacing: 0.01em;
|
|
894
|
+
}
|
|
895
|
+
.summary .revert-btn {
|
|
896
|
+
height: 20px;
|
|
897
|
+
padding: 0 8px;
|
|
898
|
+
background: transparent;
|
|
899
|
+
border: 0;
|
|
900
|
+
border-radius: 4px;
|
|
901
|
+
cursor: pointer;
|
|
902
|
+
font-family: inherit;
|
|
903
|
+
letter-spacing: inherit;
|
|
904
|
+
font-size: 10px;
|
|
905
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
906
|
+
line-height: 1;
|
|
907
|
+
transition: background 0.12s, color 0.12s;
|
|
908
|
+
width: auto;
|
|
909
|
+
}
|
|
910
|
+
.summary .revert-btn:hover:not(:disabled) {
|
|
911
|
+
background: var(--md-sys-color-surface, #ffffff);
|
|
912
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
913
|
+
}
|
|
914
|
+
.summary.reverted .revert-btn,
|
|
915
|
+
.summary .revert-btn:disabled {
|
|
916
|
+
display: none;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/* ── Follow-up question ───────────────────────────── */
|
|
920
|
+
.followup {
|
|
921
|
+
font-size: 10.5px;
|
|
922
|
+
color: var(--md-sys-color-on-secondary-container, #92400e);
|
|
923
|
+
background: var(--md-sys-color-secondary-container, #fffbeb);
|
|
924
|
+
border-left: 2px solid var(--md-sys-color-secondary, #f59e0b);
|
|
925
|
+
padding: 5px 8px;
|
|
926
|
+
border-radius: 0 5px 5px 0;
|
|
927
|
+
margin-top: 4px;
|
|
928
|
+
line-height: 1.45;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/* ── "대화가 길어졌습니다" 안내 — 조용한 한 줄, 강조하지 않는다 ───── */
|
|
932
|
+
.fold-notice {
|
|
933
|
+
display: flex;
|
|
934
|
+
align-items: center;
|
|
935
|
+
gap: 6px;
|
|
936
|
+
margin: 0 0 4px;
|
|
937
|
+
padding: 5px 8px;
|
|
938
|
+
border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
939
|
+
border-radius: 8px;
|
|
940
|
+
background: var(--md-sys-color-surface-container-low, #f8fafc);
|
|
941
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
942
|
+
font-size: 10.5px;
|
|
943
|
+
line-height: 1.45;
|
|
944
|
+
}
|
|
945
|
+
.fold-notice md-icon {
|
|
946
|
+
--md-icon-size: 14px;
|
|
947
|
+
flex-shrink: 0;
|
|
948
|
+
}
|
|
949
|
+
.fold-notice .fn-text {
|
|
950
|
+
flex: 1;
|
|
951
|
+
min-width: 0;
|
|
952
|
+
}
|
|
953
|
+
/* 새 대화 — 권유이므로 강조색 글자만(채운 버튼은 지시처럼 읽힌다). */
|
|
954
|
+
.fold-notice .fn-new {
|
|
955
|
+
flex-shrink: 0;
|
|
956
|
+
border: 0;
|
|
957
|
+
background: none;
|
|
958
|
+
color: var(--md-sys-color-primary, #0f766e);
|
|
959
|
+
font-family: inherit;
|
|
960
|
+
font-size: 10.5px;
|
|
961
|
+
font-weight: 650;
|
|
962
|
+
cursor: pointer;
|
|
963
|
+
padding: 2px 4px;
|
|
964
|
+
border-radius: 5px;
|
|
965
|
+
white-space: nowrap;
|
|
966
|
+
}
|
|
967
|
+
.fold-notice .fn-new:hover {
|
|
968
|
+
background: var(--md-sys-color-surface-container-high, #e2e8f0);
|
|
969
|
+
}
|
|
970
|
+
.fold-notice .fn-close {
|
|
971
|
+
flex-shrink: 0;
|
|
972
|
+
border: 0;
|
|
973
|
+
background: none;
|
|
974
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
975
|
+
cursor: pointer;
|
|
976
|
+
padding: 0;
|
|
977
|
+
display: inline-flex;
|
|
978
|
+
}
|
|
979
|
+
.fold-notice .fn-close md-icon {
|
|
980
|
+
--md-icon-size: 13px;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/* ── 조치 제안 — AI 는 제안까지, 실행은 사용자 ───── */
|
|
984
|
+
.proposals {
|
|
985
|
+
display: flex;
|
|
986
|
+
flex-direction: column;
|
|
987
|
+
gap: 4px;
|
|
988
|
+
margin-top: 6px;
|
|
989
|
+
}
|
|
990
|
+
.proposal {
|
|
991
|
+
display: flex;
|
|
992
|
+
align-items: center;
|
|
993
|
+
gap: 8px;
|
|
994
|
+
padding: 6px 8px 6px 7px;
|
|
995
|
+
border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
996
|
+
/* 왼쪽 강조선 — 되돌릴 수 없는 행동임을 조용히 구분한다(색만으로 소리치지 않는다). */
|
|
997
|
+
border-left: 3px solid var(--md-sys-color-primary, #0f766e);
|
|
998
|
+
border-radius: 8px;
|
|
999
|
+
background: var(--md-sys-color-surface-container-low, #f8fafc);
|
|
1000
|
+
}
|
|
1001
|
+
.proposal md-icon {
|
|
1002
|
+
--md-icon-size: 16px;
|
|
1003
|
+
color: var(--md-sys-color-primary, #0f766e);
|
|
1004
|
+
flex-shrink: 0;
|
|
1005
|
+
}
|
|
1006
|
+
.proposal .pbody {
|
|
1007
|
+
display: flex;
|
|
1008
|
+
flex-direction: column;
|
|
1009
|
+
gap: 1px;
|
|
1010
|
+
min-width: 0;
|
|
1011
|
+
flex: 1;
|
|
1012
|
+
}
|
|
1013
|
+
.proposal .plabel {
|
|
1014
|
+
font-size: 11.5px;
|
|
1015
|
+
font-weight: 650;
|
|
1016
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
1017
|
+
overflow-wrap: anywhere;
|
|
1018
|
+
}
|
|
1019
|
+
.proposal .preason {
|
|
1020
|
+
font-size: 10.5px;
|
|
1021
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
1022
|
+
overflow-wrap: anywhere;
|
|
1023
|
+
}
|
|
1024
|
+
/* 실행 버튼 — 이 대화의 유일한 "되돌릴 수 없는" 행동이므로 채운 버튼 하나로 분명하게. */
|
|
1025
|
+
.proposal .run {
|
|
1026
|
+
flex-shrink: 0;
|
|
1027
|
+
padding: 4px 12px;
|
|
1028
|
+
border: 0;
|
|
1029
|
+
border-radius: 999px;
|
|
1030
|
+
background: var(--md-sys-color-primary, #0f766e);
|
|
1031
|
+
color: var(--md-sys-color-on-primary, #ffffff);
|
|
1032
|
+
font-family: inherit;
|
|
1033
|
+
font-size: 11px;
|
|
1034
|
+
font-weight: 650;
|
|
1035
|
+
letter-spacing: 0.01em;
|
|
1036
|
+
cursor: pointer;
|
|
1037
|
+
transition: filter 0.12s;
|
|
1038
|
+
}
|
|
1039
|
+
.proposal .run:hover:not(:disabled) {
|
|
1040
|
+
filter: brightness(1.08);
|
|
1041
|
+
}
|
|
1042
|
+
.proposal .run:disabled {
|
|
1043
|
+
background: var(--md-sys-color-surface-container-high, #e2e8f0);
|
|
1044
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
1045
|
+
cursor: default;
|
|
1046
|
+
}
|
|
1047
|
+
.proposal.sent {
|
|
1048
|
+
border-left-color: var(--md-sys-color-outline, #94a3b8);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
|
|
1052
|
+
.grounding-warning {
|
|
1053
|
+
display: flex;
|
|
1054
|
+
align-items: flex-start;
|
|
1055
|
+
gap: 6px;
|
|
1056
|
+
margin-top: 4px;
|
|
1057
|
+
padding: 5px 8px;
|
|
1058
|
+
border-radius: 6px;
|
|
1059
|
+
border: 1px solid var(--md-sys-color-error, #dc2626);
|
|
1060
|
+
background: var(--md-sys-color-error-container, #fef2f2);
|
|
1061
|
+
color: var(--md-sys-color-on-error-container, #7f1d1d);
|
|
1062
|
+
font-size: 10.5px;
|
|
1063
|
+
line-height: 1.45;
|
|
1064
|
+
}
|
|
1065
|
+
.grounding-warning md-icon {
|
|
1066
|
+
--md-icon-size: 14px;
|
|
1067
|
+
flex-shrink: 0;
|
|
1068
|
+
}
|
|
1069
|
+
.grounding-warning .ids {
|
|
1070
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1071
|
+
font-weight: 600;
|
|
1072
|
+
word-break: break-all;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/* ── Tool usages fold-able 박스 — AI 도구 호출 trace ───── */
|
|
1076
|
+
.tool-usages {
|
|
1077
|
+
margin-top: 4px;
|
|
1078
|
+
border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
1079
|
+
border-radius: 6px;
|
|
1080
|
+
background: var(--md-sys-color-surface-container-low, #f8fafc);
|
|
1081
|
+
overflow: hidden;
|
|
1082
|
+
font-size: 10.5px;
|
|
1083
|
+
}
|
|
1084
|
+
.tool-usages-header {
|
|
1085
|
+
display: flex;
|
|
1086
|
+
align-items: center;
|
|
1087
|
+
gap: 6px;
|
|
1088
|
+
width: 100%;
|
|
1089
|
+
padding: 5px 8px;
|
|
1090
|
+
background: transparent;
|
|
1091
|
+
border: 0;
|
|
1092
|
+
cursor: pointer;
|
|
1093
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
1094
|
+
font-family: inherit;
|
|
1095
|
+
font-size: inherit;
|
|
1096
|
+
letter-spacing: inherit;
|
|
1097
|
+
text-align: left;
|
|
1098
|
+
}
|
|
1099
|
+
.tool-usages-header:hover {
|
|
1100
|
+
background: var(--md-sys-color-surface-container, #f1f5f9);
|
|
1101
|
+
}
|
|
1102
|
+
.tool-usages-icon,
|
|
1103
|
+
.tool-usages-chevron {
|
|
1104
|
+
--md-icon-size: 14px;
|
|
1105
|
+
flex-shrink: 0;
|
|
1106
|
+
}
|
|
1107
|
+
.tool-usages-summary {
|
|
1108
|
+
flex: 1;
|
|
1109
|
+
font-weight: 500;
|
|
1110
|
+
}
|
|
1111
|
+
.tool-usages-counts {
|
|
1112
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
1113
|
+
font-weight: 400;
|
|
1114
|
+
font-size: 10px;
|
|
1115
|
+
}
|
|
1116
|
+
.tool-usages-list {
|
|
1117
|
+
list-style: none;
|
|
1118
|
+
margin: 0;
|
|
1119
|
+
padding: 0 8px 8px;
|
|
1120
|
+
display: flex;
|
|
1121
|
+
flex-direction: column;
|
|
1122
|
+
gap: 6px;
|
|
1123
|
+
}
|
|
1124
|
+
.tool-usage-item {
|
|
1125
|
+
border-left: 2px solid var(--md-sys-color-outline-variant, #cbd5e1);
|
|
1126
|
+
padding: 4px 8px 4px 8px;
|
|
1127
|
+
background: var(--md-sys-color-surface, #ffffff);
|
|
1128
|
+
border-radius: 0 4px 4px 0;
|
|
1129
|
+
}
|
|
1130
|
+
.tool-usage-item.kind-read {
|
|
1131
|
+
border-left-color: var(--md-sys-color-tertiary, #10b981);
|
|
1132
|
+
}
|
|
1133
|
+
.tool-usage-item.kind-write {
|
|
1134
|
+
border-left-color: var(--md-sys-color-primary, #1e293b);
|
|
1135
|
+
}
|
|
1136
|
+
.tool-usage-item.kind-unknown {
|
|
1137
|
+
border-left-color: var(--md-sys-color-error, #b91c1c);
|
|
1138
|
+
}
|
|
1139
|
+
.tool-usage-head {
|
|
1140
|
+
display: flex;
|
|
1141
|
+
align-items: center;
|
|
1142
|
+
gap: 6px;
|
|
1143
|
+
margin-bottom: 3px;
|
|
1144
|
+
}
|
|
1145
|
+
.tool-usage-step {
|
|
1146
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
1147
|
+
font-size: 10px;
|
|
1148
|
+
}
|
|
1149
|
+
.tool-usage-name {
|
|
1150
|
+
font-weight: 600;
|
|
1151
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
1152
|
+
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
|
1153
|
+
font-size: 10.5px;
|
|
1154
|
+
}
|
|
1155
|
+
.tool-usage-kind {
|
|
1156
|
+
font-size: 9.5px;
|
|
1157
|
+
padding: 1px 5px;
|
|
1158
|
+
border-radius: 3px;
|
|
1159
|
+
text-transform: uppercase;
|
|
1160
|
+
letter-spacing: 0.05em;
|
|
1161
|
+
font-weight: 600;
|
|
1162
|
+
}
|
|
1163
|
+
.tool-usage-kind.kind-read {
|
|
1164
|
+
color: var(--md-sys-color-on-tertiary, #ffffff);
|
|
1165
|
+
background: var(--md-sys-color-tertiary, #10b981);
|
|
1166
|
+
}
|
|
1167
|
+
.tool-usage-kind.kind-write {
|
|
1168
|
+
color: var(--md-sys-color-on-primary, #ffffff);
|
|
1169
|
+
background: var(--md-sys-color-primary, #1e293b);
|
|
1170
|
+
}
|
|
1171
|
+
.tool-usage-kind.kind-unknown {
|
|
1172
|
+
color: var(--md-sys-color-on-error, #ffffff);
|
|
1173
|
+
background: var(--md-sys-color-error, #b91c1c);
|
|
1174
|
+
}
|
|
1175
|
+
/* 판단 파이프라인 — 훑어 읽는 목록. 배지로 결과를 구분하고 원본은 눌러서 본다. */
|
|
1176
|
+
.tool-usages.has-problem {
|
|
1177
|
+
border-color: var(--md-sys-color-error, #dc2626);
|
|
1178
|
+
}
|
|
1179
|
+
.tool-usages-counts .tu-flag {
|
|
1180
|
+
font-weight: 650;
|
|
1181
|
+
}
|
|
1182
|
+
.tool-usages-counts .tu-flag.bad {
|
|
1183
|
+
color: var(--md-sys-color-error, #dc2626);
|
|
1184
|
+
}
|
|
1185
|
+
.tu-turn {
|
|
1186
|
+
list-style: none;
|
|
1187
|
+
margin: 4px 0 2px;
|
|
1188
|
+
padding: 0 8px;
|
|
1189
|
+
font-size: 9.5px;
|
|
1190
|
+
font-weight: 650;
|
|
1191
|
+
letter-spacing: 0.04em;
|
|
1192
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
1193
|
+
text-transform: uppercase;
|
|
1194
|
+
}
|
|
1195
|
+
.tool-usage-head {
|
|
1196
|
+
cursor: pointer;
|
|
1197
|
+
align-items: center;
|
|
1198
|
+
}
|
|
1199
|
+
.tool-usage-item.outcome-rejected,
|
|
1200
|
+
.tool-usage-item.outcome-error {
|
|
1201
|
+
border-left-color: var(--md-sys-color-error, #dc2626);
|
|
1202
|
+
}
|
|
1203
|
+
.tool-usage-item.outcome-proposed {
|
|
1204
|
+
border-left-color: var(--md-sys-color-primary, #0f766e);
|
|
1205
|
+
}
|
|
1206
|
+
.tool-usage-item.outcome-folded {
|
|
1207
|
+
opacity: 0.7;
|
|
1208
|
+
}
|
|
1209
|
+
.tu-badge {
|
|
1210
|
+
flex-shrink: 0;
|
|
1211
|
+
padding: 0 5px;
|
|
1212
|
+
border-radius: 999px;
|
|
1213
|
+
font-size: 9px;
|
|
1214
|
+
font-weight: 700;
|
|
1215
|
+
line-height: 15px;
|
|
1216
|
+
background: var(--md-sys-color-surface-container-high, #e2e8f0);
|
|
1217
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
1218
|
+
}
|
|
1219
|
+
.tu-badge.rejected,
|
|
1220
|
+
.tu-badge.error {
|
|
1221
|
+
background: var(--md-sys-color-error-container, #fef2f2);
|
|
1222
|
+
color: var(--md-sys-color-error, #dc2626);
|
|
1223
|
+
}
|
|
1224
|
+
.tu-badge.proposed {
|
|
1225
|
+
background: var(--md-sys-color-primary, #0f766e);
|
|
1226
|
+
color: var(--md-sys-color-on-primary, #fff);
|
|
1227
|
+
}
|
|
1228
|
+
/* 한 줄 요약 — 넘치면 자른다. 목록은 훑는 곳이고 읽는 곳이 아니다. */
|
|
1229
|
+
.tu-line {
|
|
1230
|
+
flex: 1;
|
|
1231
|
+
min-width: 0;
|
|
1232
|
+
overflow: hidden;
|
|
1233
|
+
text-overflow: ellipsis;
|
|
1234
|
+
white-space: nowrap;
|
|
1235
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
1236
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1237
|
+
font-size: 9.5px;
|
|
1238
|
+
}
|
|
1239
|
+
.tu-more {
|
|
1240
|
+
--md-icon-size: 13px;
|
|
1241
|
+
flex-shrink: 0;
|
|
1242
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
.tool-usage-args,
|
|
1246
|
+
.tool-usage-result {
|
|
1247
|
+
margin: 2px 0 0;
|
|
1248
|
+
padding: 4px 6px;
|
|
1249
|
+
background: var(--md-sys-color-surface-container-high, rgba(15, 23, 42, 0.04));
|
|
1250
|
+
border-radius: 3px;
|
|
1251
|
+
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
|
1252
|
+
font-size: 10px;
|
|
1253
|
+
line-height: 1.4;
|
|
1254
|
+
white-space: pre-wrap;
|
|
1255
|
+
overflow-x: auto;
|
|
1256
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
1257
|
+
max-height: 180px;
|
|
1258
|
+
overflow-y: auto;
|
|
1259
|
+
}
|
|
1260
|
+
.tool-usage-args {
|
|
1261
|
+
border-left: 2px solid var(--md-sys-color-outline-variant, #cbd5e1);
|
|
1262
|
+
}
|
|
1263
|
+
.tool-usage-result {
|
|
1264
|
+
border-left: 2px solid var(--md-sys-color-outline, #94a3b8);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/* ── Hover actions (assistant 메시지만, Material icon button) ── */
|
|
1268
|
+
.msg-actions {
|
|
1269
|
+
display: flex;
|
|
1270
|
+
gap: 2px;
|
|
1271
|
+
margin-top: 2px;
|
|
1272
|
+
padding: 0;
|
|
1273
|
+
opacity: 0;
|
|
1274
|
+
transition: opacity 0.15s;
|
|
1275
|
+
pointer-events: none;
|
|
1276
|
+
}
|
|
1277
|
+
.msg-wrap:hover .msg-actions,
|
|
1278
|
+
.msg-wrap:focus-within .msg-actions {
|
|
1279
|
+
opacity: 1;
|
|
1280
|
+
pointer-events: auto;
|
|
1281
|
+
}
|
|
1282
|
+
.msg-actions .msg-action {
|
|
1283
|
+
width: 22px;
|
|
1284
|
+
height: 22px;
|
|
1285
|
+
padding: 0;
|
|
1286
|
+
background: transparent;
|
|
1287
|
+
border: 0;
|
|
1288
|
+
border-radius: 5px;
|
|
1289
|
+
cursor: pointer;
|
|
1290
|
+
display: inline-flex;
|
|
1291
|
+
align-items: center;
|
|
1292
|
+
justify-content: center;
|
|
1293
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
1294
|
+
transition:
|
|
1295
|
+
background 0.12s,
|
|
1296
|
+
color 0.12s;
|
|
1297
|
+
}
|
|
1298
|
+
.msg-actions .msg-action:hover:not(:disabled) {
|
|
1299
|
+
background: var(--md-sys-color-surface-container-high, #e2e8f0);
|
|
1300
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
1301
|
+
}
|
|
1302
|
+
.msg-actions .msg-action:disabled {
|
|
1303
|
+
opacity: 0.4;
|
|
1304
|
+
cursor: default;
|
|
1305
|
+
}
|
|
1306
|
+
.msg-actions .msg-action.confirmed {
|
|
1307
|
+
color: var(--md-sys-color-tertiary, #10b981);
|
|
1308
|
+
}
|
|
1309
|
+
.msg-actions .msg-action md-icon {
|
|
1310
|
+
--md-icon-size: 14px;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/* ── Error banner ─────────────────────────────────── */
|
|
1314
|
+
.error {
|
|
1315
|
+
color: var(--md-sys-color-on-error-container, #b91c1c);
|
|
1316
|
+
padding: 7px 11px;
|
|
1317
|
+
background: var(--md-sys-color-error-container, #fef2f2);
|
|
1318
|
+
border: 1px solid var(--md-sys-color-error, #fee2e2);
|
|
1319
|
+
border-radius: 8px;
|
|
1320
|
+
margin: 0 16px 8px;
|
|
1321
|
+
font-size: 10.5px;
|
|
1322
|
+
line-height: 1.5;
|
|
1323
|
+
display: flex;
|
|
1324
|
+
align-items: center;
|
|
1325
|
+
justify-content: space-between;
|
|
1326
|
+
gap: 8px;
|
|
1327
|
+
}
|
|
1328
|
+
.error .error-text {
|
|
1329
|
+
flex: 1;
|
|
1330
|
+
}
|
|
1331
|
+
.error .retry-btn {
|
|
1332
|
+
flex-shrink: 0;
|
|
1333
|
+
padding: 3px 8px;
|
|
1334
|
+
font-size: 10px;
|
|
1335
|
+
font-family: inherit;
|
|
1336
|
+
letter-spacing: inherit;
|
|
1337
|
+
background: var(--md-sys-color-error, #b91c1c);
|
|
1338
|
+
color: var(--md-sys-color-on-error, #ffffff);
|
|
1339
|
+
border: 0;
|
|
1340
|
+
border-radius: 6px;
|
|
1341
|
+
cursor: pointer;
|
|
1342
|
+
transition: opacity 0.15s;
|
|
1343
|
+
width: auto;
|
|
1344
|
+
height: auto;
|
|
1345
|
+
}
|
|
1346
|
+
.error .retry-btn:hover {
|
|
1347
|
+
opacity: 0.88;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/* ── Composer (actions + input 통합 영역) ──────────── */
|
|
1351
|
+
.composer {
|
|
1352
|
+
border-top: 1px solid var(--md-sys-color-outline-variant, #f1f5f9);
|
|
1353
|
+
background: var(--md-sys-color-surface, #ffffff);
|
|
1354
|
+
display: flex;
|
|
1355
|
+
flex-direction: column;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/* ── Mini action row (메시지 시작 후) ──────────────── */
|
|
1359
|
+
.actions-row {
|
|
1360
|
+
display: flex;
|
|
1361
|
+
gap: 6px;
|
|
1362
|
+
padding: 6px 14px 4px;
|
|
1363
|
+
flex-wrap: wrap;
|
|
1364
|
+
align-items: center;
|
|
1365
|
+
}
|
|
1366
|
+
/* Material 3 assist-chip 톤 — height 24, font label-small */
|
|
1367
|
+
.action {
|
|
1368
|
+
display: inline-flex;
|
|
1369
|
+
align-items: center;
|
|
1370
|
+
justify-content: center;
|
|
1371
|
+
gap: 5px;
|
|
1372
|
+
height: 24px;
|
|
1373
|
+
padding: 0 10px;
|
|
1374
|
+
font-size: 11px;
|
|
1375
|
+
font-family: inherit;
|
|
1376
|
+
letter-spacing: inherit;
|
|
1377
|
+
line-height: 1;
|
|
1378
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
1379
|
+
background: transparent;
|
|
1380
|
+
border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
1381
|
+
border-radius: 8px;
|
|
1382
|
+
cursor: pointer;
|
|
1383
|
+
transition:
|
|
1384
|
+
background 0.15s,
|
|
1385
|
+
border-color 0.15s,
|
|
1386
|
+
color 0.15s;
|
|
1387
|
+
width: auto;
|
|
1388
|
+
vertical-align: middle;
|
|
1389
|
+
}
|
|
1390
|
+
.action:hover {
|
|
1391
|
+
background: var(--md-sys-color-surface-container, #f8fafc);
|
|
1392
|
+
border-color: var(--md-sys-color-outline, #cbd5e1);
|
|
1393
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
1394
|
+
}
|
|
1395
|
+
.action:active {
|
|
1396
|
+
transform: scale(0.97);
|
|
1397
|
+
}
|
|
1398
|
+
.action md-icon {
|
|
1399
|
+
--md-icon-size: 14px;
|
|
1400
|
+
opacity: 0.7;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/* ── Inline examples (action row 의 "예시" 토글 시) ──── */
|
|
1404
|
+
.inline-examples {
|
|
1405
|
+
padding: 4px 16px 0;
|
|
1406
|
+
display: flex;
|
|
1407
|
+
flex-direction: column;
|
|
1408
|
+
gap: 3px;
|
|
1409
|
+
}
|
|
1410
|
+
.inline-examples .ex-row {
|
|
1411
|
+
display: flex;
|
|
1412
|
+
flex-wrap: wrap;
|
|
1413
|
+
gap: 4px;
|
|
1414
|
+
}
|
|
1415
|
+
.inline-examples .ex-row .ex-label {
|
|
1416
|
+
font-size: 9px;
|
|
1417
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
1418
|
+
letter-spacing: 0.05em;
|
|
1419
|
+
text-transform: uppercase;
|
|
1420
|
+
margin-right: 2px;
|
|
1421
|
+
align-self: center;
|
|
1422
|
+
font-weight: 600;
|
|
1423
|
+
}
|
|
1424
|
+
.inline-examples .ex {
|
|
1425
|
+
all: unset;
|
|
1426
|
+
cursor: pointer;
|
|
1427
|
+
padding: 2px 8px;
|
|
1428
|
+
font-size: 10px;
|
|
1429
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
1430
|
+
background: var(--md-sys-color-surface-container, #f8fafc);
|
|
1431
|
+
border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
|
|
1432
|
+
border-radius: 10px;
|
|
1433
|
+
transition: background 0.15s;
|
|
1434
|
+
}
|
|
1435
|
+
.inline-examples .ex:hover {
|
|
1436
|
+
background: var(--md-sys-color-surface-container-high, #f1f5f9);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/* ── Input area ───────────────────────────────────── */
|
|
1440
|
+
/* border-top 은 .composer 가 담당 (actions-row 가 같은 wrapper 안) */
|
|
1441
|
+
.input-row {
|
|
1442
|
+
display: flex;
|
|
1443
|
+
gap: 8px;
|
|
1444
|
+
padding: 6px 14px 10px;
|
|
1445
|
+
background: transparent;
|
|
1446
|
+
align-items: flex-end;
|
|
1447
|
+
}
|
|
1448
|
+
/* 메시지 시작 전 — actions-row 가 없을 때는 input-row 가 직접 border-top */
|
|
1449
|
+
.composer.no-actions .input-row {
|
|
1450
|
+
padding-top: 10px;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/* mention popup — textarea wrapper 가 relative 컨텍스트, popup 이 textarea
|
|
1454
|
+
와 정확히 같은 폭/좌표 (좌우 끝 정렬). send 버튼 영역은 제외. */
|
|
1455
|
+
.textarea-wrap {
|
|
1456
|
+
flex: 1;
|
|
1457
|
+
position: relative;
|
|
1458
|
+
display: flex;
|
|
1459
|
+
min-width: 0;
|
|
1460
|
+
}
|
|
1461
|
+
.textarea-wrap textarea {
|
|
1462
|
+
flex: 1;
|
|
1463
|
+
width: 100%;
|
|
1464
|
+
}
|
|
1465
|
+
.mention-popup {
|
|
1466
|
+
bottom: calc(100% + 4px);
|
|
1467
|
+
left: 0;
|
|
1468
|
+
right: 0;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
textarea {
|
|
1472
|
+
flex: 1;
|
|
1473
|
+
padding: 8px 12px;
|
|
1474
|
+
border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
1475
|
+
border-radius: 12px;
|
|
1476
|
+
font: inherit;
|
|
1477
|
+
letter-spacing: inherit;
|
|
1478
|
+
resize: none;
|
|
1479
|
+
min-height: 36px;
|
|
1480
|
+
max-height: 140px;
|
|
1481
|
+
outline: none;
|
|
1482
|
+
transition:
|
|
1483
|
+
border-color 0.2s,
|
|
1484
|
+
box-shadow 0.2s;
|
|
1485
|
+
background: var(--md-sys-color-surface, #ffffff);
|
|
1486
|
+
color: var(--md-sys-color-on-surface, #0f172a);
|
|
1487
|
+
}
|
|
1488
|
+
textarea::placeholder {
|
|
1489
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
1490
|
+
}
|
|
1491
|
+
textarea:focus {
|
|
1492
|
+
border-color: var(--md-sys-color-outline, #cbd5e1);
|
|
1493
|
+
box-shadow: 0 0 0 3px var(--md-sys-color-primary-container, rgba(15, 23, 42, 0.08));
|
|
1494
|
+
}
|
|
1495
|
+
textarea:disabled {
|
|
1496
|
+
background: var(--md-sys-color-surface-container, #f8fafc);
|
|
1497
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
1498
|
+
cursor: not-allowed;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/* ── Send button — ChatGPT 스타일 원형 ↑ (.input-row 안의 button 만) ─── */
|
|
1502
|
+
.input-row > button {
|
|
1503
|
+
width: 32px;
|
|
1504
|
+
height: 32px;
|
|
1505
|
+
padding: 0;
|
|
1506
|
+
background: var(--md-sys-color-primary, #1e293b);
|
|
1507
|
+
color: var(--md-sys-color-on-primary, #ffffff);
|
|
1508
|
+
border: 0;
|
|
1509
|
+
border-radius: 50%;
|
|
1510
|
+
cursor: pointer;
|
|
1511
|
+
display: flex;
|
|
1512
|
+
align-items: center;
|
|
1513
|
+
justify-content: center;
|
|
1514
|
+
transition:
|
|
1515
|
+
background 0.15s,
|
|
1516
|
+
transform 0.1s,
|
|
1517
|
+
opacity 0.15s;
|
|
1518
|
+
flex-shrink: 0;
|
|
1519
|
+
align-self: flex-end;
|
|
1520
|
+
margin-bottom: 2px;
|
|
1521
|
+
}
|
|
1522
|
+
.input-row > button:disabled {
|
|
1523
|
+
background: var(--md-sys-color-surface-container-highest, #e2e8f0);
|
|
1524
|
+
color: var(--md-sys-color-outline, #94a3b8);
|
|
1525
|
+
cursor: not-allowed;
|
|
1526
|
+
}
|
|
1527
|
+
.input-row > button:hover:not(:disabled) {
|
|
1528
|
+
opacity: 0.88;
|
|
1529
|
+
}
|
|
1530
|
+
.input-row > button:active:not(:disabled) {
|
|
1531
|
+
transform: scale(0.94);
|
|
1532
|
+
}
|
|
1533
|
+
.input-row > button md-icon {
|
|
1534
|
+
--md-icon-size: 18px;
|
|
1535
|
+
}
|
|
1536
|
+
.input-row > button .spinner {
|
|
1537
|
+
width: 12px;
|
|
1538
|
+
height: 12px;
|
|
1539
|
+
border: 1.8px solid currentColor;
|
|
1540
|
+
border-right-color: transparent;
|
|
1541
|
+
border-radius: 50%;
|
|
1542
|
+
animation: spin 0.7s linear infinite;
|
|
1543
|
+
}
|
|
1544
|
+
@keyframes spin {
|
|
1545
|
+
to {
|
|
1546
|
+
transform: rotate(360deg);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
`
|
|
1550
|
+
]; }
|
|
1551
|
+
connectedCallback() {
|
|
1552
|
+
super.connectedCallback();
|
|
1553
|
+
// 첫 마운트 시 sessionId 가 이미 set 돼있어도 loadHistory 는 첫 render 후 updated()
|
|
1554
|
+
// 가 처리 — 단일 진입점으로 일관 (이전엔 connectedCallback 과 updated 양쪽이 호출 →
|
|
1555
|
+
// 동시 fetch race 발생).
|
|
1556
|
+
}
|
|
1557
|
+
updated(changed) {
|
|
1558
|
+
/*
|
|
1559
|
+
* 밖에서 넣어 준 질문을 자동 전송한다(오류 토스트의 "AI에게 묻기" 등).
|
|
1560
|
+
*
|
|
1561
|
+
* sessionId 가 붙기 전에 보내면 대화에 남지 않으므로 세션이 준비될 때까지 기다린다.
|
|
1562
|
+
* 같은 문장을 두 번 보내지 않도록 보낸 값을 기억한다 — 재렌더마다 updated 가 다시 돌기 때문이다.
|
|
1563
|
+
*/
|
|
1564
|
+
if (this.autoAsk && this.autoAsk !== this._autoAsked && this.sessionId && !this.busy) {
|
|
1565
|
+
this._autoAsked = this.autoAsk;
|
|
1566
|
+
this.input = this.autoAsk;
|
|
1567
|
+
this.send();
|
|
1568
|
+
}
|
|
1569
|
+
if (changed.has('sessionId')) {
|
|
1570
|
+
// sessionId 가 바뀌면 (set / unset / 다른 보드 전환) 이전 세션의 모든 클라이언트
|
|
1571
|
+
// 컨텍스트 정리 — lines / pickedMentions / errorMessage / input 모두 새지 않도록.
|
|
1572
|
+
// 보드 단위 격리 (ChatSession 1:1 boardId) 가 깨지는 회귀 방지.
|
|
1573
|
+
this.lines = [];
|
|
1574
|
+
this.pickedMentions.clear();
|
|
1575
|
+
this.pickedUserMentions.clear();
|
|
1576
|
+
this.cachedUserCandidates = [];
|
|
1577
|
+
this.errorMessage = undefined;
|
|
1578
|
+
this.input = '';
|
|
1579
|
+
this.participants = [];
|
|
1580
|
+
this._seenMessageIds.clear(); // 세션 전환 — 에코 dedup 컨텍스트 리셋
|
|
1581
|
+
if (this.sessionId) {
|
|
1582
|
+
this.loadHistory();
|
|
1583
|
+
this._startPresence(); // C4: roster 로드 + heartbeat 시작
|
|
1584
|
+
this._startMessageSubscription(); // C4: 메시지 실시간 방송 구독
|
|
1585
|
+
}
|
|
1586
|
+
else {
|
|
1587
|
+
this._stopPresence();
|
|
1588
|
+
this._stopMessageSubscription();
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
// 자동 스크롤
|
|
1592
|
+
const ms = this.renderRoot.querySelector('.messages');
|
|
1593
|
+
if (ms)
|
|
1594
|
+
ms.scrollTop = ms.scrollHeight;
|
|
1595
|
+
}
|
|
1596
|
+
disconnectedCallback() {
|
|
1597
|
+
this._stopPresence();
|
|
1598
|
+
this._stopMessageSubscription();
|
|
1599
|
+
super.disconnectedCallback();
|
|
1600
|
+
}
|
|
1601
|
+
// ── C4 presence/roster ─────────────────────────────────────────────────────
|
|
1602
|
+
_startPresence() {
|
|
1603
|
+
this._stopPresence();
|
|
1604
|
+
this._loadParticipants();
|
|
1605
|
+
this._touchPresence(); // 즉시 1회 — 내 online 즉시 반영
|
|
1606
|
+
this._heartbeatTimer = setInterval(() => {
|
|
1607
|
+
this._touchPresence();
|
|
1608
|
+
this._loadParticipants();
|
|
1609
|
+
this._presenceTick++; // online 판정 재평가(시간 경과)
|
|
1610
|
+
}, PRESENCE_HEARTBEAT_MS);
|
|
1611
|
+
}
|
|
1612
|
+
_stopPresence() {
|
|
1613
|
+
if (this._heartbeatTimer) {
|
|
1614
|
+
clearInterval(this._heartbeatTimer);
|
|
1615
|
+
this._heartbeatTimer = undefined;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
// ── C4 그룹챗 — 메시지 실시간 방송 구독 ─────────────────────────────────────
|
|
1619
|
+
_startMessageSubscription() {
|
|
1620
|
+
this._stopMessageSubscription();
|
|
1621
|
+
if (!this.sessionId)
|
|
1622
|
+
return;
|
|
1623
|
+
try {
|
|
1624
|
+
this._messageSub = client
|
|
1625
|
+
.subscribe({ query: CHAT_MESSAGE_SUBSCRIPTION, variables: { sessionId: this.sessionId } })
|
|
1626
|
+
.subscribe({
|
|
1627
|
+
next: ({ data }) => this._onRemoteMessage(data?.chatMessageActivity),
|
|
1628
|
+
error: () => {
|
|
1629
|
+
/* 구독 에러는 채팅 본 기능 무영향 — 히스토리 쿼리로 폴백 */
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
catch {
|
|
1634
|
+
/* 구독 미지원 환경 — 조용히 스킵 */
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
_stopMessageSubscription() {
|
|
1638
|
+
if (this._messageSub) {
|
|
1639
|
+
try {
|
|
1640
|
+
this._messageSub.unsubscribe();
|
|
1641
|
+
}
|
|
1642
|
+
catch {
|
|
1643
|
+
/* 이미 닫힘 */
|
|
1644
|
+
}
|
|
1645
|
+
this._messageSub = undefined;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
/** 서버 ChatMessage → ChatLine. */
|
|
1649
|
+
_msgToLine(m) {
|
|
1650
|
+
return remoteMessageToLine(m);
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* 원격(방송) 메시지 수신 — 에코·중복은 id 로 차단, 신규는 transcript 에 append.
|
|
1654
|
+
* 자기 발신 메시지의 id 는 전송 reconcile 에서 이미 _seenMessageIds 에 등록됨.
|
|
1655
|
+
*/
|
|
1656
|
+
_onRemoteMessage(m) {
|
|
1657
|
+
const next = receiveRemoteMessage(this.lines, this._seenMessageIds, m);
|
|
1658
|
+
if (!next)
|
|
1659
|
+
return;
|
|
1660
|
+
this._seenMessageIds = next.seen;
|
|
1661
|
+
this.lines = next.lines;
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* 전송한 optimistic 라인을 서버 id 로 reconcile (index 비의존 — _localId 로 탐색).
|
|
1665
|
+
* 에코가 응답보다 먼저 도착해 이미 append 됐으면 optimistic 중복 라인을 제거한다.
|
|
1666
|
+
*/
|
|
1667
|
+
_reconcileSentLine(localId, serverId, patch) {
|
|
1668
|
+
const next = reconcileSentLine(this.lines, this._seenMessageIds, localId, serverId, patch);
|
|
1669
|
+
this._seenMessageIds = next.seen;
|
|
1670
|
+
this.lines = next.lines;
|
|
1671
|
+
}
|
|
1672
|
+
async _loadParticipants() {
|
|
1673
|
+
if (!this.sessionId)
|
|
1674
|
+
return;
|
|
1675
|
+
try {
|
|
1676
|
+
const result = await client.query({
|
|
1677
|
+
query: CHAT_PARTICIPANTS_QUERY,
|
|
1678
|
+
variables: { sessionId: this.sessionId },
|
|
1679
|
+
fetchPolicy: 'network-only'
|
|
1680
|
+
});
|
|
1681
|
+
this.participants = result.data?.chatSessionParticipants ?? [];
|
|
1682
|
+
}
|
|
1683
|
+
catch {
|
|
1684
|
+
/* roster 로드 실패는 채팅 본 기능 무영향 — 조용히 무시 */
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
async _touchPresence() {
|
|
1688
|
+
if (!this.sessionId)
|
|
1689
|
+
return;
|
|
1690
|
+
try {
|
|
1691
|
+
await client.mutate({ mutation: TOUCH_PRESENCE_MUTATION, variables: { sessionId: this.sessionId } });
|
|
1692
|
+
}
|
|
1693
|
+
catch {
|
|
1694
|
+
/* heartbeat 실패 무시 */
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
/** lastSeenAt 이 TTL 이내면 online. (_presenceTick 의존으로 주기 재평가.) */
|
|
1698
|
+
_isOnline(lastSeenAt) {
|
|
1699
|
+
void this._presenceTick;
|
|
1700
|
+
if (!lastSeenAt)
|
|
1701
|
+
return false;
|
|
1702
|
+
const t = new Date(lastSeenAt).getTime();
|
|
1703
|
+
if (!Number.isFinite(t))
|
|
1704
|
+
return false;
|
|
1705
|
+
const age = Date.now() - t;
|
|
1706
|
+
return age >= 0 ? age <= PRESENCE_TTL_MS : true;
|
|
1707
|
+
}
|
|
1708
|
+
async loadHistory() {
|
|
1709
|
+
if (!this.sessionId)
|
|
1710
|
+
return;
|
|
1711
|
+
try {
|
|
1712
|
+
const result = await client.query({
|
|
1713
|
+
query: CHAT_MESSAGES_QUERY,
|
|
1714
|
+
variables: { sessionId: this.sessionId, limit: 200, offset: 0 },
|
|
1715
|
+
fetchPolicy: 'network-only'
|
|
1716
|
+
});
|
|
1717
|
+
const msgs = result.data?.chatMessages ?? [];
|
|
1718
|
+
/* 서버 이력을 다시 불러오면 화면에 전부 보인다 → 접은 지점 해제(안 하면 화면과 에이전트 문맥이
|
|
1719
|
+
* 어긋난 채 남는다). 전송 후에는 해제하지 않는다 — 접힌 화면이 그대로이므로 계속 숨겨야 한다. */
|
|
1720
|
+
this._truncateAfterId = undefined;
|
|
1721
|
+
this.lines = msgs.map((m) => this._msgToLine(m));
|
|
1722
|
+
// 로드된 메시지 id 를 seen 에 등록 — 이후 방송 에코 dedup 기준.
|
|
1723
|
+
this._seenMessageIds = new Set(msgs.map((m) => m.id).filter(Boolean));
|
|
1724
|
+
}
|
|
1725
|
+
catch (e) {
|
|
1726
|
+
this.errorMessage = `${i18next.t('board-ai.text.failed-to-load-history')}: ${e.message ?? e}`;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
render() {
|
|
1730
|
+
const hasMessages = this.lines.length > 0;
|
|
1731
|
+
return html `
|
|
1732
|
+
${this.toastMessage
|
|
1733
|
+
? html `<div class="toast show">${this.toastMessage}</div>`
|
|
1734
|
+
: nothing}
|
|
1735
|
+
${this.renderSessionTabs()}
|
|
1736
|
+
${this.renderRoster()}
|
|
1737
|
+
<div class="messages" @click=${this.onBubbleClick}>
|
|
1738
|
+
${!hasMessages ? this.renderEmpty() : nothing}
|
|
1739
|
+
${(() => {
|
|
1740
|
+
const myEmail = this._myEmail;
|
|
1741
|
+
return this.lines.map((line, idx) => {
|
|
1742
|
+
const prev = this.lines[idx - 1];
|
|
1743
|
+
const showLabel = line.role === 'assistant' && (!prev || prev.role !== 'assistant');
|
|
1744
|
+
/* 그룹챗: 다른 참여자의 user 메시지는 좌측 + 보낸이 이름 표시(연속 동일 발신자는 1회만).
|
|
1745
|
+
* 판정은 email 비교. **내 email 을 모를 때는 '남'으로 단정하지 않는다** — 내 발화를 남의 것으로
|
|
1746
|
+
* 잘못 보여주는 쪽이 더 해롭다(로그인 정보가 아직 안 온 순간 등). 내 email 을 아는 상태에서
|
|
1747
|
+
* 발신자 email 이 다르거나 비어 있으면 다른 계정으로 본다. */
|
|
1748
|
+
const isOther = line.role === 'user' && !!myEmail && line.senderEmail !== myEmail;
|
|
1749
|
+
const prevSameSender = !!prev && prev.role === 'user' && prev.senderEmail === line.senderEmail;
|
|
1750
|
+
const showSenderLabel = isOther && !prevSameSender;
|
|
1751
|
+
const content = (line.content ?? '').trim();
|
|
1752
|
+
const isMarkdown = line.role !== 'system' && content.length > 0;
|
|
1753
|
+
const showActions = line.role !== 'system' && !line.pending && content.length > 0;
|
|
1754
|
+
return html `
|
|
1755
|
+
${showLabel
|
|
1756
|
+
? html `<div class="msg-label">${i18next.t('board-ai.label.ai')}</div>`
|
|
1757
|
+
: showSenderLabel
|
|
1758
|
+
? html `<div class="msg-label sender">${line.senderName || i18next.t('board-ai.label.participant')}</div>`
|
|
1759
|
+
: nothing}
|
|
1760
|
+
<div class="msg-wrap ${line.role} ${isOther ? 'other' : ''}">
|
|
1761
|
+
<div
|
|
1762
|
+
class="msg ${line.role} ${isOther ? 'other' : ''} ${line.pending ? 'pending' : ''} ${line.systemOpen
|
|
1763
|
+
? 'open'
|
|
1764
|
+
: ''}">
|
|
1765
|
+
${line.pending
|
|
1766
|
+
? html `<span class="typing-dots"><span></span><span></span><span></span></span>`
|
|
1767
|
+
: isMarkdown
|
|
1768
|
+
? html `<div class="md-body">${unsafeHTML(renderMarkdown(content))}</div>`
|
|
1769
|
+
: line.role === 'system'
|
|
1770
|
+
? html `<span
|
|
1771
|
+
class="sys-text"
|
|
1772
|
+
title=${i18next.t('board-ai.text.toggle-system-note', { defaultValue: '눌러서 펼치기' })}
|
|
1773
|
+
@click=${() => this.toggleSystemNote(idx)}
|
|
1774
|
+
>${content}</span
|
|
1775
|
+
>`
|
|
1776
|
+
: content}
|
|
1777
|
+
${line.patchId
|
|
1778
|
+
? html `
|
|
1779
|
+
<div class="summary ${this.revertedPatchIds.has(line.patchId) ? 'reverted' : ''}">
|
|
1780
|
+
<md-icon>check_circle</md-icon>
|
|
1781
|
+
<span class="summary-text">
|
|
1782
|
+
${this.revertedPatchIds.has(line.patchId)
|
|
1783
|
+
? i18next.t('board-ai.text.reverted')
|
|
1784
|
+
: i18next.t('board-ai.text.applied-changes')}
|
|
1785
|
+
</span>
|
|
1786
|
+
<button
|
|
1787
|
+
class="revert-btn"
|
|
1788
|
+
?disabled=${this.revertedPatchIds.has(line.patchId)}
|
|
1789
|
+
@click=${() => line.patchId && this.onRevertClick(line.patchId)}>
|
|
1790
|
+
${i18next.t('board-ai.button.revert')}
|
|
1791
|
+
</button>
|
|
1792
|
+
</div>
|
|
1793
|
+
`
|
|
1794
|
+
: nothing}
|
|
1795
|
+
${line.followUp
|
|
1796
|
+
? html `<div class="followup">${unsafeHTML(renderMarkdown(line.followUp.trim()))}</div>`
|
|
1797
|
+
: nothing}
|
|
1798
|
+
${line.proposals && line.proposals.length > 0 ? this.renderProposals(line) : nothing}
|
|
1799
|
+
${line.groundingWarnings && line.groundingWarnings.length > 0
|
|
1800
|
+
? this.renderGroundingWarning(line)
|
|
1801
|
+
: nothing}
|
|
1802
|
+
${line.toolUsages && line.toolUsages.length > 0
|
|
1803
|
+
? this.renderToolUsages(idx, line)
|
|
1804
|
+
: nothing}
|
|
1805
|
+
</div>
|
|
1806
|
+
${showActions ? this.renderMessageActions(idx, line, content) : nothing}
|
|
1807
|
+
</div>
|
|
1808
|
+
`;
|
|
1809
|
+
});
|
|
1810
|
+
})()}
|
|
1811
|
+
</div>
|
|
1812
|
+
${this.errorMessage
|
|
1813
|
+
? html `
|
|
1814
|
+
<div class="error">
|
|
1815
|
+
<span class="error-text">${this.errorMessage}</span>
|
|
1816
|
+
${this.lastFailedInput
|
|
1817
|
+
? html `
|
|
1818
|
+
<button class="retry-btn" @click=${this.retryLastSend}>
|
|
1819
|
+
${i18next.t('board-ai.button.retry')}
|
|
1820
|
+
</button>
|
|
1821
|
+
`
|
|
1822
|
+
: nothing}
|
|
1823
|
+
</div>
|
|
1824
|
+
`
|
|
1825
|
+
: nothing}
|
|
1826
|
+
${this.renderFoldNotice()}
|
|
1827
|
+
<div class="composer ${hasMessages ? '' : 'no-actions'}">
|
|
1828
|
+
${hasMessages
|
|
1829
|
+
? html `
|
|
1830
|
+
<div class="actions-row">
|
|
1831
|
+
<button
|
|
1832
|
+
class="action"
|
|
1833
|
+
@click=${() => (this.examplesOpen = !this.examplesOpen)}
|
|
1834
|
+
title=${i18next.t('board-ai.text.show-examples-tooltip')}>
|
|
1835
|
+
<md-icon>${this.examplesOpen ? 'close' : 'lightbulb'}</md-icon>
|
|
1836
|
+
${this.examplesOpen
|
|
1837
|
+
? i18next.t('board-ai.button.close')
|
|
1838
|
+
: i18next.t('board-ai.button.examples')}
|
|
1839
|
+
</button>
|
|
1840
|
+
</div>
|
|
1841
|
+
${this.examplesOpen ? this.renderInlineExamples() : nothing}
|
|
1842
|
+
`
|
|
1843
|
+
: nothing}
|
|
1844
|
+
<div class="input-row">
|
|
1845
|
+
<div class="textarea-wrap">
|
|
1846
|
+
<textarea
|
|
1847
|
+
.value=${this.input}
|
|
1848
|
+
@input=${(e) => this.onTextareaInput(e)}
|
|
1849
|
+
@keydown=${(e) => this.onKeyDown(e)}
|
|
1850
|
+
@blur=${() => {
|
|
1851
|
+
// 포커스를 popup item 으로 옮기는 click 보다 blur 가 먼저 — popup item 의
|
|
1852
|
+
// mousedown.preventDefault() 가 처리하지만 안전망으로 짧은 지연 후 닫기.
|
|
1853
|
+
setTimeout(() => this.closeMentionPopup(), 150);
|
|
1854
|
+
}}
|
|
1855
|
+
?disabled=${this.busy}
|
|
1856
|
+
rows="1"
|
|
1857
|
+
placeholder=${this.placeholder ?? i18next.t('board-ai.text.input-placeholder')}></textarea>
|
|
1858
|
+
${this.mentionOpen
|
|
1859
|
+
? html `
|
|
1860
|
+
<ox-mention-popup
|
|
1861
|
+
class="mention-popup"
|
|
1862
|
+
.results=${this.mentionResults}
|
|
1863
|
+
.activeIndex=${this.mentionActiveIndex}
|
|
1864
|
+
.overflow=${this.mentionOverflow}
|
|
1865
|
+
@mention-active-change=${(e) => {
|
|
1866
|
+
// 마우스 hover — keyboard nav 와 동일하게 select + centerTo
|
|
1867
|
+
const idx = e.detail?.index;
|
|
1868
|
+
if (typeof idx === 'number' && idx >= 0 && idx < this.mentionResults.length) {
|
|
1869
|
+
this.mentionActiveIndex = idx;
|
|
1870
|
+
this.previewActiveCandidate();
|
|
1871
|
+
}
|
|
1872
|
+
}}
|
|
1873
|
+
@mention-select=${(e) => this.applyMention(e.detail.candidate)}></ox-mention-popup>
|
|
1874
|
+
`
|
|
1875
|
+
: nothing}
|
|
1876
|
+
</div>
|
|
1877
|
+
<button
|
|
1878
|
+
?disabled=${this.busy || !this.input.trim()}
|
|
1879
|
+
@click=${this.send}
|
|
1880
|
+
title=${i18next.t('board-ai.text.send-tooltip')}>
|
|
1881
|
+
${this.busy
|
|
1882
|
+
? html `<span class="spinner"></span>`
|
|
1883
|
+
: html `<md-icon>arrow_upward</md-icon>`}
|
|
1884
|
+
</button>
|
|
1885
|
+
</div>
|
|
1886
|
+
</div>
|
|
1887
|
+
`;
|
|
1888
|
+
}
|
|
1889
|
+
onKeyDown(e) {
|
|
1890
|
+
// Mention popup 활성 시 — 화살표/Enter/Tab/Esc 가로채서 popup 네비게이션
|
|
1891
|
+
if (this.mentionOpen) {
|
|
1892
|
+
if (e.key === 'ArrowDown') {
|
|
1893
|
+
e.preventDefault();
|
|
1894
|
+
if (this.mentionResults.length > 0) {
|
|
1895
|
+
this.mentionActiveIndex =
|
|
1896
|
+
(this.mentionActiveIndex + 1) % this.mentionResults.length;
|
|
1897
|
+
this.previewActiveCandidate();
|
|
1898
|
+
}
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
if (e.key === 'ArrowUp') {
|
|
1902
|
+
e.preventDefault();
|
|
1903
|
+
if (this.mentionResults.length > 0) {
|
|
1904
|
+
this.mentionActiveIndex =
|
|
1905
|
+
(this.mentionActiveIndex - 1 + this.mentionResults.length) %
|
|
1906
|
+
this.mentionResults.length;
|
|
1907
|
+
this.previewActiveCandidate();
|
|
1908
|
+
}
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
1912
|
+
if (this.mentionResults.length > 0) {
|
|
1913
|
+
e.preventDefault();
|
|
1914
|
+
this.applyMention(this.mentionResults[this.mentionActiveIndex].candidate);
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
// 매칭 0개면 popup 닫고 일반 동작 fall through
|
|
1918
|
+
this.closeMentionPopup();
|
|
1919
|
+
}
|
|
1920
|
+
if (e.key === 'Escape') {
|
|
1921
|
+
e.preventDefault();
|
|
1922
|
+
this.closeMentionPopup();
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
if (e.key === 'Enter' && !e.shiftKey && !this.busy) {
|
|
1927
|
+
e.preventDefault();
|
|
1928
|
+
this.send();
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
/** input 이벤트 — value 즉시 반영 + popup 갱신은 150ms 디바운스 (F-Finder 와 동일).
|
|
1932
|
+
* big board 에서 매 keystroke filter 비용 절감 + 한국어 IME 조합 중 깜빡임 완화. */
|
|
1933
|
+
onTextareaInput(e) {
|
|
1934
|
+
const ta = e.target;
|
|
1935
|
+
this.input = ta.value;
|
|
1936
|
+
if (this.inputDebounceTimer)
|
|
1937
|
+
clearTimeout(this.inputDebounceTimer);
|
|
1938
|
+
this.inputDebounceTimer = setTimeout(() => {
|
|
1939
|
+
this.refreshMentionPopup(ta);
|
|
1940
|
+
}, 150);
|
|
1941
|
+
}
|
|
1942
|
+
/** 현재 cursor 위치를 보고 mention popup 활성/비활성 + 후보 갱신.
|
|
1943
|
+
* `#` (컴포넌트) / `@` (카탈로그) / `/` (slash 템플릿) 한 번에 하나만 활성. */
|
|
1944
|
+
refreshMentionPopup(ta) {
|
|
1945
|
+
const cursor = ta.selectionStart ?? ta.value.length;
|
|
1946
|
+
const triggers = ['#', '@', '/'];
|
|
1947
|
+
let chosen = null;
|
|
1948
|
+
for (const t of triggers) {
|
|
1949
|
+
const ctx = extractMentionContext(ta.value, cursor, t);
|
|
1950
|
+
if (!ctx)
|
|
1951
|
+
continue;
|
|
1952
|
+
if (!chosen || ctx.hashPos > chosen.hashPos) {
|
|
1953
|
+
chosen = { ...ctx, trigger: t };
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
if (!chosen) {
|
|
1957
|
+
if (this.mentionOpen)
|
|
1958
|
+
this.closeMentionPopup();
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
const wasOpen = this.mentionOpen;
|
|
1962
|
+
const cap = 50;
|
|
1963
|
+
let results;
|
|
1964
|
+
if (chosen.trigger === '#' && this.searchProvider && chosen.query) {
|
|
1965
|
+
// 호스트의 SceneSearchEngine 위임 — F-Finder 와 동일 검색 룰. cap 만 chat 측에서 적용.
|
|
1966
|
+
results = this.searchProvider(chosen.query).slice(0, cap);
|
|
1967
|
+
}
|
|
1968
|
+
else if (chosen.trigger === '@') {
|
|
1969
|
+
// `@` = 도메인 사용자 멘션. host 가 userProvider set 했으면 query → users 비동기 fetch.
|
|
1970
|
+
// userProvider 미설정 / 결과 없음 → 빈 popup (멀티유저 미사용 환경).
|
|
1971
|
+
// async 라 즉시 반환은 빈 결과; await 후 다시 set 하는 패턴.
|
|
1972
|
+
results = this.cachedUserCandidates
|
|
1973
|
+
? filterMentionCandidates(this.cachedUserCandidates, chosen.query, cap)
|
|
1974
|
+
: [];
|
|
1975
|
+
// 비동기 갱신 — 결과 도착하면 popup 다시 갱신
|
|
1976
|
+
this.refreshUserCandidatesAsync(chosen.query, chosen.hashPos);
|
|
1977
|
+
}
|
|
1978
|
+
else {
|
|
1979
|
+
let all;
|
|
1980
|
+
if (chosen.trigger === '#') {
|
|
1981
|
+
// 빈 query 의 # 또는 searchProvider 미설정 — 로컬 enumeration 으로 전체 후보 표시
|
|
1982
|
+
const liveBoard = this.boardProvider ? this.boardProvider() : this.currentBoard;
|
|
1983
|
+
all = buildMentionCandidates(liveBoard?.components ?? []);
|
|
1984
|
+
}
|
|
1985
|
+
else {
|
|
1986
|
+
// `/` = 카탈로그 + slash 템플릿 통합 (Notion `/` 패턴). 둘 다 "삽입 가능 항목".
|
|
1987
|
+
all = [...this.cachedCatalogCandidates, ...this.cachedSlashCandidates];
|
|
1988
|
+
}
|
|
1989
|
+
results = filterMentionCandidates(all, chosen.query, cap);
|
|
1990
|
+
}
|
|
1991
|
+
// overflow 계산 — 전체 매칭 중 cap 으로 잘려나간 수 (popup 'N more' 표기)
|
|
1992
|
+
// (cap 잘라서 반환하므로, 전체 카운트는 자유 검색 시 알기 어려움.
|
|
1993
|
+
// 엄격한 overflow 추적 대신 results.length === cap 이면 추정 표시)
|
|
1994
|
+
const overflow = results.length === cap ? 0 : 0;
|
|
1995
|
+
this.mentionResults = results;
|
|
1996
|
+
this.mentionOverflow = overflow;
|
|
1997
|
+
this.mentionActiveIndex = 0;
|
|
1998
|
+
this.mentionHashPos = chosen.hashPos;
|
|
1999
|
+
this.mentionTrigger = chosen.trigger;
|
|
2000
|
+
this.mentionOpen = true;
|
|
2001
|
+
// 첫 open 시 — 사용자 selection 스냅샷 (취소 시 원복용)
|
|
2002
|
+
if (!wasOpen && chosen.trigger === '#') {
|
|
2003
|
+
this.originalSelectedRefids = (this.selectedRefids ?? []).slice();
|
|
2004
|
+
}
|
|
2005
|
+
// 매칭된 후보 outline 만 갱신 — 카메라/선택 변경은 commit (Enter/click) 시점에만.
|
|
2006
|
+
this.highlightMatchedCandidates();
|
|
2007
|
+
}
|
|
2008
|
+
/** `@` query 에 대한 userProvider 비동기 호출 — 응답 도착 시 popup 결과 갱신.
|
|
2009
|
+
* race 방지: token 으로 최신 호출 식별 (늦게 도착한 응답 무시). */
|
|
2010
|
+
async refreshUserCandidatesAsync(query, hashPos) {
|
|
2011
|
+
if (!this.userProvider)
|
|
2012
|
+
return;
|
|
2013
|
+
const token = ++this.userFetchToken;
|
|
2014
|
+
try {
|
|
2015
|
+
const users = await Promise.resolve(this.userProvider(query));
|
|
2016
|
+
if (token !== this.userFetchToken)
|
|
2017
|
+
return; // 더 최신 호출이 있음
|
|
2018
|
+
// popup 이 닫혔거나 trigger / hashPos 가 바뀌면 무시
|
|
2019
|
+
if (!this.mentionOpen || this.mentionTrigger !== '@' || this.mentionHashPos !== hashPos) {
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
this.cachedUserCandidates = buildUserCandidates(users);
|
|
2023
|
+
this.mentionResults = filterMentionCandidates(this.cachedUserCandidates, query, 50);
|
|
2024
|
+
this.mentionActiveIndex = 0;
|
|
2025
|
+
}
|
|
2026
|
+
catch (e) {
|
|
2027
|
+
// 조용히 무시 — 사용자 입력 흐름 방해 X. 디버그 시 console 확인 가능.
|
|
2028
|
+
console.warn('[board-ai-chat] userProvider failed:', e);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
/** popup 후보들 outline highlight — 카메라/선택 변경 없음.
|
|
2032
|
+
* 타이핑 직후 (refreshMentionPopup) 에서 호출 — 사용자가 active 를 명시 이동하지
|
|
2033
|
+
* 않은 상태이므로 임의의 첫 항목을 선택하거나 카메라 이동시키지 않음.
|
|
2034
|
+
* # trigger 일 때만 활성. */
|
|
2035
|
+
highlightMatchedCandidates() {
|
|
2036
|
+
if (this.mentionTrigger !== '#')
|
|
2037
|
+
return;
|
|
2038
|
+
const allRefids = [];
|
|
2039
|
+
for (const r of this.mentionResults) {
|
|
2040
|
+
if (typeof r.candidate.refid === 'number')
|
|
2041
|
+
allRefids.push(r.candidate.refid);
|
|
2042
|
+
}
|
|
2043
|
+
this.dispatchActions([{ action: 'highlightComponents', refids: allRefids }]);
|
|
2044
|
+
}
|
|
2045
|
+
/** active 후보 live preview — 사용자가 ↑↓ 키 / popup 마우스 hover 로 명시적으로
|
|
2046
|
+
* active 를 옮긴 시점에 호출. F-Finder 동급 3-action: highlight + select + centerTo.
|
|
2047
|
+
* centerTo 는 150ms 디바운스 — 빠른 nav 시 카메라 애니메이션 race 방지.
|
|
2048
|
+
* # trigger 일 때만 활성. */
|
|
2049
|
+
previewActiveCandidate() {
|
|
2050
|
+
if (this.mentionTrigger !== '#')
|
|
2051
|
+
return;
|
|
2052
|
+
const allRefids = [];
|
|
2053
|
+
for (const r of this.mentionResults) {
|
|
2054
|
+
if (typeof r.candidate.refid === 'number')
|
|
2055
|
+
allRefids.push(r.candidate.refid);
|
|
2056
|
+
}
|
|
2057
|
+
const active = this.mentionResults[this.mentionActiveIndex];
|
|
2058
|
+
const activeRefid = active && typeof active.candidate.refid === 'number'
|
|
2059
|
+
? active.candidate.refid
|
|
2060
|
+
: undefined;
|
|
2061
|
+
const actions = [{ action: 'highlightComponents', refids: allRefids }];
|
|
2062
|
+
if (typeof activeRefid === 'number') {
|
|
2063
|
+
actions.push({ action: 'selectComponents', refids: [activeRefid] });
|
|
2064
|
+
}
|
|
2065
|
+
this.dispatchActions(actions);
|
|
2066
|
+
if (this.centerDebounceTimer)
|
|
2067
|
+
clearTimeout(this.centerDebounceTimer);
|
|
2068
|
+
if (typeof activeRefid === 'number') {
|
|
2069
|
+
this.centerDebounceTimer = setTimeout(() => {
|
|
2070
|
+
this.dispatchActions([
|
|
2071
|
+
{ action: 'centerToComponent', refid: activeRefid, animated: true }
|
|
2072
|
+
]);
|
|
2073
|
+
}, 150);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
dispatchActions(actions) {
|
|
2077
|
+
this.dispatchEvent(new CustomEvent('board-action-execute', {
|
|
2078
|
+
detail: { actions, sessionId: this.sessionId },
|
|
2079
|
+
bubbles: true,
|
|
2080
|
+
composed: true
|
|
2081
|
+
}));
|
|
2082
|
+
}
|
|
2083
|
+
/** popup 닫기. commit=false (취소) 면 # 의 경우:
|
|
2084
|
+
* - highlight 정리 (highlightComponents [])
|
|
2085
|
+
* - 원래 selection 으로 원복
|
|
2086
|
+
* commit=true (확정) 면 현재 preview 상태 유지 + highlight 만 정리. */
|
|
2087
|
+
closeMentionPopup(commit = false) {
|
|
2088
|
+
if (!this.mentionOpen)
|
|
2089
|
+
return;
|
|
2090
|
+
const wasHashTrigger = this.mentionTrigger === '#';
|
|
2091
|
+
this.mentionOpen = false;
|
|
2092
|
+
this.mentionResults = [];
|
|
2093
|
+
this.mentionOverflow = 0;
|
|
2094
|
+
this.mentionActiveIndex = 0;
|
|
2095
|
+
this.mentionHashPos = -1;
|
|
2096
|
+
if (wasHashTrigger) {
|
|
2097
|
+
// popup 닫힘 → highlight 항상 정리 (확정/취소 모두)
|
|
2098
|
+
const cleanupActions = [{ action: 'highlightComponents', refids: [] }];
|
|
2099
|
+
if (!commit) {
|
|
2100
|
+
// 취소 — 원래 selection 으로 원복
|
|
2101
|
+
cleanupActions.push({
|
|
2102
|
+
action: 'selectComponents',
|
|
2103
|
+
refids: this.originalSelectedRefids
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
this.dispatchActions(cleanupActions);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
/** 후보 1개를 textarea 에 적용 + popup 닫고 cursor 재배치. */
|
|
2110
|
+
applyMention(candidate) {
|
|
2111
|
+
const ta = this.renderRoot.querySelector('textarea');
|
|
2112
|
+
if (!ta || this.mentionHashPos < 0) {
|
|
2113
|
+
this.closeMentionPopup();
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
const cursor = ta.selectionStart ?? ta.value.length;
|
|
2117
|
+
const r = applyMentionSelection(ta.value, cursor, this.mentionHashPos, candidate, this.mentionTrigger);
|
|
2118
|
+
this.input = r.text;
|
|
2119
|
+
// # trigger + refid 보유 후보 — commit 시점에만 select + centerTo (사용자 의도 확정 순간).
|
|
2120
|
+
// 동시에 picks 로 기록: textarea 에는 깨끗한 #label, server 에는 정확한 refid 전달.
|
|
2121
|
+
if (this.mentionTrigger === '#' &&
|
|
2122
|
+
typeof candidate.refid === 'number') {
|
|
2123
|
+
this.pickedMentions.set(candidate.label, candidate.refid);
|
|
2124
|
+
this.dispatchActions([
|
|
2125
|
+
{ action: 'selectComponents', refids: [candidate.refid] },
|
|
2126
|
+
{ action: 'centerToComponent', refid: candidate.refid, animated: true }
|
|
2127
|
+
]);
|
|
2128
|
+
}
|
|
2129
|
+
else if (this.mentionTrigger === '@' &&
|
|
2130
|
+
typeof candidate.userId === 'string' &&
|
|
2131
|
+
candidate.userId.length > 0) {
|
|
2132
|
+
// @user 멘션 — userId 박제. 보드 액션은 발사 안 함 (사용자는 컴포넌트 아님).
|
|
2133
|
+
this.pickedUserMentions.set(candidate.label, candidate.userId);
|
|
2134
|
+
}
|
|
2135
|
+
this.closeMentionPopup(true); // commit — preview selection 유지, highlight 만 정리
|
|
2136
|
+
requestAnimationFrame(() => {
|
|
2137
|
+
const ta2 = this.renderRoot.querySelector('textarea');
|
|
2138
|
+
ta2?.focus();
|
|
2139
|
+
ta2?.setSelectionRange(r.cursorPos, r.cursorPos);
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
// ── Empty state with onboarding guide ──────────────────────────
|
|
2143
|
+
/** 예시 그룹 — i18n 키. localize mixin 이 언어 변경 시 자동 재렌더. */
|
|
2144
|
+
/**
|
|
2145
|
+
* 화면에 그릴 예시 — 호스트가 준 것이 있으면 그것, 없으면 보드 기본값(i18n 키를 여기서 푼다).
|
|
2146
|
+
*
|
|
2147
|
+
* 두 자리(빈 화면 · 접힌 줄)가 같은 값을 써야 한다. 전에는 둘이 각각 `EXAMPLE_GROUPS` 를 직접
|
|
2148
|
+
* 읽었고, 그러면 호스트 값을 한쪽만 반영하는 상태가 만들어질 수 있었다.
|
|
2149
|
+
*/
|
|
2150
|
+
get exampleGroups() {
|
|
2151
|
+
if (this.examples?.length)
|
|
2152
|
+
return this.examples;
|
|
2153
|
+
return OxBoardAIChat_1.EXAMPLE_GROUPS.map(group => ({
|
|
2154
|
+
label: i18next.t(group.labelKey),
|
|
2155
|
+
items: group.itemKeys.map(key => i18next.t(key))
|
|
2156
|
+
}));
|
|
2157
|
+
}
|
|
2158
|
+
static { this.EXAMPLE_GROUPS = [
|
|
2159
|
+
{
|
|
2160
|
+
labelKey: 'board-ai.label.create',
|
|
2161
|
+
itemKeys: [
|
|
2162
|
+
'board-ai.example.create-monitoring-dashboard',
|
|
2163
|
+
'board-ai.example.create-welcome-screen'
|
|
2164
|
+
]
|
|
2165
|
+
},
|
|
2166
|
+
{
|
|
2167
|
+
labelKey: 'board-ai.label.edit',
|
|
2168
|
+
itemKeys: [
|
|
2169
|
+
'board-ai.example.edit-align-distribute',
|
|
2170
|
+
'board-ai.example.edit-resize-board'
|
|
2171
|
+
]
|
|
2172
|
+
},
|
|
2173
|
+
{
|
|
2174
|
+
labelKey: 'board-ai.label.style',
|
|
2175
|
+
itemKeys: [
|
|
2176
|
+
'board-ai.example.style-dark-mode',
|
|
2177
|
+
'board-ai.example.style-rounded-shadow'
|
|
2178
|
+
]
|
|
2179
|
+
}
|
|
2180
|
+
]; }
|
|
2181
|
+
/** 보드의 세션 탭 — 다중 세션 활성화 시 패널 상단. host 가 sessions 비워두면 표시 X.
|
|
2182
|
+
* 레이블은 session.name 우선, 없으면 fallback "세션 N". */
|
|
2183
|
+
renderSessionTabs() {
|
|
2184
|
+
if (!this.sessions || this.sessions.length === 0)
|
|
2185
|
+
return nothing;
|
|
2186
|
+
return html `
|
|
2187
|
+
<div class="session-tabs" role="tablist">
|
|
2188
|
+
${this.sessions.map((s, i) => {
|
|
2189
|
+
const isActive = s.id === this.sessionId;
|
|
2190
|
+
const fallback = i18next.t('board-ai.text.session-tab-label', {
|
|
2191
|
+
n: i + 1,
|
|
2192
|
+
defaultValue: `세션 ${i + 1}`
|
|
2193
|
+
});
|
|
2194
|
+
const label = s.name?.trim() || fallback;
|
|
2195
|
+
const tooltip = s.createdAt
|
|
2196
|
+
? `${label} · ${new Date(s.createdAt).toLocaleString()}`
|
|
2197
|
+
: label;
|
|
2198
|
+
return html `
|
|
2199
|
+
<button
|
|
2200
|
+
class="session-tab ${isActive ? 'active' : ''}"
|
|
2201
|
+
role="tab"
|
|
2202
|
+
aria-selected=${isActive ? 'true' : 'false'}
|
|
2203
|
+
title=${tooltip}
|
|
2204
|
+
@click=${() => this._onSessionTabClick(s.id)}>
|
|
2205
|
+
${label}
|
|
2206
|
+
</button>
|
|
2207
|
+
`;
|
|
2208
|
+
})}
|
|
2209
|
+
<button
|
|
2210
|
+
class="session-tab-new"
|
|
2211
|
+
title=${i18next.t('board-ai.button.new-chat', {
|
|
2212
|
+
defaultValue: '새 대화'
|
|
2213
|
+
})}
|
|
2214
|
+
@click=${this._onSessionCreateClick}>
|
|
2215
|
+
<md-icon>add</md-icon>
|
|
2216
|
+
<span>${i18next.t('board-ai.button.new-chat', { defaultValue: '새 대화' })}</span>
|
|
2217
|
+
</button>
|
|
2218
|
+
</div>
|
|
2219
|
+
`;
|
|
2220
|
+
}
|
|
2221
|
+
/** C4 roster — 세션 참여자 + online 표시. 참여자 2명 미만이면 숨김(혼자면 의미 없음). */
|
|
2222
|
+
renderRoster() {
|
|
2223
|
+
if (!this.sessionId || this.participants.length < 2)
|
|
2224
|
+
return nothing;
|
|
2225
|
+
const online = this.participants.filter(p => this._isOnline(p.lastSeenAt)).length;
|
|
2226
|
+
return html `
|
|
2227
|
+
<div class="roster" role="group" aria-label="participants">
|
|
2228
|
+
<span class="roster-count" title=${`${online} online / ${this.participants.length} total`}>
|
|
2229
|
+
<md-icon>group</md-icon>${online}/${this.participants.length}
|
|
2230
|
+
</span>
|
|
2231
|
+
${this.participants.map(p => {
|
|
2232
|
+
const name = p.user?.name || p.user?.id || '?';
|
|
2233
|
+
const on = this._isOnline(p.lastSeenAt);
|
|
2234
|
+
return html `<span
|
|
2235
|
+
class="roster-chip ${on ? 'online' : 'offline'}"
|
|
2236
|
+
title=${`${name} · ${p.role}${on ? ' · online' : ''}`}>
|
|
2237
|
+
<span class="roster-dot"></span>${initials(name)}
|
|
2238
|
+
</span>`;
|
|
2239
|
+
})}
|
|
2240
|
+
</div>
|
|
2241
|
+
`;
|
|
2242
|
+
}
|
|
2243
|
+
_onSessionTabClick(sessionId) {
|
|
2244
|
+
if (sessionId === this.sessionId)
|
|
2245
|
+
return;
|
|
2246
|
+
this.dispatchEvent(new CustomEvent('session-switch', {
|
|
2247
|
+
detail: { sessionId },
|
|
2248
|
+
bubbles: true,
|
|
2249
|
+
composed: true
|
|
2250
|
+
}));
|
|
2251
|
+
}
|
|
2252
|
+
renderEmpty() {
|
|
2253
|
+
return html `
|
|
2254
|
+
<div class="empty">
|
|
2255
|
+
<div class="header">
|
|
2256
|
+
<md-icon class="icon">auto_awesome</md-icon>
|
|
2257
|
+
<div class="title">${this.intro?.title ?? i18next.t('board-ai.text.empty-title')}</div>
|
|
2258
|
+
<div class="subtitle">${this.intro?.subtitle ?? i18next.t('board-ai.text.empty-subtitle')}</div>
|
|
2259
|
+
</div>
|
|
2260
|
+
|
|
2261
|
+
<div class="trigger-row">
|
|
2262
|
+
<div class="trigger-row-label">${i18next.t('board-ai.text.triggers-heading')}</div>
|
|
2263
|
+
<div class="trigger-chips">
|
|
2264
|
+
<button
|
|
2265
|
+
class="trigger-chip"
|
|
2266
|
+
title=${i18next.t('board-ai.text.trigger-component-tooltip')}
|
|
2267
|
+
@click=${() => this.insertTrigger('#')}>
|
|
2268
|
+
<span class="trigger-char">#</span>
|
|
2269
|
+
<span class="trigger-desc">${i18next.t('board-ai.label.trigger-component')}</span>
|
|
2270
|
+
</button>
|
|
2271
|
+
<button
|
|
2272
|
+
class="trigger-chip"
|
|
2273
|
+
title=${i18next.t('board-ai.text.trigger-user-tooltip')}
|
|
2274
|
+
@click=${() => this.insertTrigger('@')}>
|
|
2275
|
+
<span class="trigger-char">@</span>
|
|
2276
|
+
<span class="trigger-desc">${i18next.t('board-ai.label.trigger-user')}</span>
|
|
2277
|
+
</button>
|
|
2278
|
+
<button
|
|
2279
|
+
class="trigger-chip"
|
|
2280
|
+
title=${i18next.t('board-ai.text.trigger-insert-tooltip')}
|
|
2281
|
+
@click=${() => this.insertTrigger('/')}>
|
|
2282
|
+
<span class="trigger-char">/</span>
|
|
2283
|
+
<span class="trigger-desc">${i18next.t('board-ai.label.trigger-insert')}</span>
|
|
2284
|
+
</button>
|
|
2285
|
+
</div>
|
|
2286
|
+
</div>
|
|
2287
|
+
|
|
2288
|
+
${this.exampleGroups.map(group => html `
|
|
2289
|
+
<div class="group">
|
|
2290
|
+
<div class="group-label">${group.label}</div>
|
|
2291
|
+
${group.items.map(text => html `
|
|
2292
|
+
<button class="example" @click=${() => this.useExample(text)}>
|
|
2293
|
+
${text}<span class="arrow">→</span>
|
|
2294
|
+
</button>
|
|
2295
|
+
`)}
|
|
2296
|
+
</div>
|
|
2297
|
+
`)}
|
|
2298
|
+
|
|
2299
|
+
<div class="footer">
|
|
2300
|
+
<span class="badge">${i18next.t('board-ai.label.korean-supported')}</span>
|
|
2301
|
+
<span class="badge">${i18next.t('board-ai.label.multi-command')}</span>
|
|
2302
|
+
<span class="badge">${i18next.t('board-ai.label.review-able')}</span>
|
|
2303
|
+
<br />
|
|
2304
|
+
${i18next.t('board-ai.text.empty-footer-notice')}
|
|
2305
|
+
</div>
|
|
2306
|
+
</div>
|
|
2307
|
+
`;
|
|
2308
|
+
}
|
|
2309
|
+
/** trigger chip 클릭 — textarea 에 trigger 문자 삽입 + focus + popup 자동 활성. */
|
|
2310
|
+
insertTrigger(trigger) {
|
|
2311
|
+
// 입력 끝에 공백이 없으면 공백 추가 후 trigger — 자연스러운 위치
|
|
2312
|
+
const needsSpace = this.input.length > 0 && !/\s$/.test(this.input);
|
|
2313
|
+
this.input = this.input + (needsSpace ? ' ' : '') + trigger;
|
|
2314
|
+
requestAnimationFrame(() => {
|
|
2315
|
+
const ta = this.renderRoot.querySelector('textarea');
|
|
2316
|
+
if (!ta)
|
|
2317
|
+
return;
|
|
2318
|
+
ta.focus();
|
|
2319
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
2320
|
+
// popup refresh — onTextareaInput 이 디바운스 후 호출되지만 직접 호출로 즉각 반영
|
|
2321
|
+
this.refreshMentionPopup(ta);
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
/** 예시 클릭 — 입력창에 채우고 포커스 (사용자가 수정 후 전송 가능) */
|
|
2325
|
+
useExample(text) {
|
|
2326
|
+
this.input = text;
|
|
2327
|
+
this.examplesOpen = false;
|
|
2328
|
+
requestAnimationFrame(() => {
|
|
2329
|
+
const ta = this.renderRoot.querySelector('textarea');
|
|
2330
|
+
ta?.focus();
|
|
2331
|
+
ta?.setSelectionRange(text.length, text.length);
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
// ── Mini actions ───────────────────────────────────────────────
|
|
2335
|
+
renderInlineExamples() {
|
|
2336
|
+
return html `
|
|
2337
|
+
<div class="inline-examples">
|
|
2338
|
+
${this.exampleGroups.map(group => html `
|
|
2339
|
+
<div class="ex-row">
|
|
2340
|
+
<span class="ex-label">${group.label}</span>
|
|
2341
|
+
${group.items.map(t => html `<button class="ex" @click=${() => this.useExample(t)}>${t}</button>`)}
|
|
2342
|
+
</div>
|
|
2343
|
+
`)}
|
|
2344
|
+
</div>
|
|
2345
|
+
`;
|
|
2346
|
+
}
|
|
2347
|
+
// ── Retry (에러 발생 시) ───────────────────────────────────────
|
|
2348
|
+
async retryLastSend() {
|
|
2349
|
+
if (!this.lastFailedInput)
|
|
2350
|
+
return;
|
|
2351
|
+
this.input = this.lastFailedInput;
|
|
2352
|
+
this.errorMessage = undefined;
|
|
2353
|
+
this.lastFailedInput = undefined;
|
|
2354
|
+
await this.send();
|
|
2355
|
+
}
|
|
2356
|
+
// ── Per-message hover action row ───────────────────────────────
|
|
2357
|
+
/**
|
|
2358
|
+
* 메시지별 hover 액션 — 텍스트 chip 으로 명확.
|
|
2359
|
+
* user : 액션 row 자체 X (자기 메시지에 시각 노이즈 X — 편집은 input 위 "새 대화" 흐름 또는 마우스 더블클릭)
|
|
2360
|
+
* assistant : 복사 + 재생성 + (patch 적용된 경우만) 되돌리기
|
|
2361
|
+
* system : 없음
|
|
2362
|
+
*/
|
|
2363
|
+
renderMessageActions(idx, line, content) {
|
|
2364
|
+
if (line.role !== 'assistant')
|
|
2365
|
+
return nothing;
|
|
2366
|
+
const isCopied = this.copiedIdx === idx;
|
|
2367
|
+
// revert 는 인라인 patch chip (메시지 안) 한 곳에만 — hover 액션에서 제외 (중복 방지).
|
|
2368
|
+
return html `
|
|
2369
|
+
<div class="msg-actions">
|
|
2370
|
+
<button
|
|
2371
|
+
class="msg-action ${isCopied ? 'confirmed' : ''}"
|
|
2372
|
+
title=${isCopied
|
|
2373
|
+
? i18next.t('board-ai.text.copied')
|
|
2374
|
+
: i18next.t('board-ai.button.copy')}
|
|
2375
|
+
@click=${() => this.copyMessage(idx, content)}>
|
|
2376
|
+
<md-icon>${isCopied ? 'check' : 'content_copy'}</md-icon>
|
|
2377
|
+
</button>
|
|
2378
|
+
<button
|
|
2379
|
+
class="msg-action"
|
|
2380
|
+
?disabled=${this.busy}
|
|
2381
|
+
title=${i18next.t('board-ai.button.regenerate')}
|
|
2382
|
+
@click=${() => this.regenerateAssistant(idx)}>
|
|
2383
|
+
<md-icon>refresh</md-icon>
|
|
2384
|
+
</button>
|
|
2385
|
+
</div>
|
|
2386
|
+
`;
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* "대화가 길어졌습니다" 안내 — **사실만** 알리고 결정은 사용자에게 맡긴다.
|
|
2390
|
+
*
|
|
2391
|
+
* 주제가 바뀌었는지 판정하지 않는다: 판정하면 오탐이 생기고, 오탐이 생기면 사용자는 안내 자체를
|
|
2392
|
+
* 무시한다(접지 경고에서 배운 것과 같다). 근거는 "앞부분이 프롬프트에서 접혔다" 는 객관적 사실뿐이다.
|
|
2393
|
+
*
|
|
2394
|
+
* 왜 알려야 하는가: 한 대화에 주제가 여럿 섞이면 (1) 옛 맥락이 새 답을 오염시키고 (2) 요약이
|
|
2395
|
+
* 뭉개지고 (3) 목록의 대화 이름이 거짓말이 된다. 그걸 아는 사람은 사용자뿐이다.
|
|
2396
|
+
*/
|
|
2397
|
+
renderFoldNotice() {
|
|
2398
|
+
const folded = this.historyFolded;
|
|
2399
|
+
if (!folded || this.foldNoticeDismissed)
|
|
2400
|
+
return nothing;
|
|
2401
|
+
return html `
|
|
2402
|
+
<div class="fold-notice">
|
|
2403
|
+
<md-icon>unfold_less</md-icon>
|
|
2404
|
+
<span class="fn-text">
|
|
2405
|
+
${folded.summarized
|
|
2406
|
+
? i18next.t('board-ai.text.history-folded-summarized', {
|
|
2407
|
+
count: folded.omitted,
|
|
2408
|
+
defaultValue: '대화가 길어져 앞부분 {count}개가 요약으로 접혔습니다. 다른 주제라면 새 대화가 낫습니다.'
|
|
2409
|
+
})
|
|
2410
|
+
: i18next.t('board-ai.text.history-folded', {
|
|
2411
|
+
count: folded.omitted,
|
|
2412
|
+
defaultValue: '대화가 길어져 앞부분 {count}개가 생략되었습니다. 다른 주제라면 새 대화가 낫습니다.'
|
|
2413
|
+
})}
|
|
2414
|
+
</span>
|
|
2415
|
+
<button class="fn-new" @click=${this.startNewFromNotice}>
|
|
2416
|
+
${i18next.t('board-ai.button.new-chat')}
|
|
2417
|
+
</button>
|
|
2418
|
+
<button class="fn-close" title=${i18next.t('board-ai.button.close')} @click=${() => (this.foldNoticeDismissed = true)}>
|
|
2419
|
+
<md-icon>close</md-icon>
|
|
2420
|
+
</button>
|
|
2421
|
+
</div>
|
|
2422
|
+
`;
|
|
2423
|
+
}
|
|
2424
|
+
/**
|
|
2425
|
+
* 조치 제안 카드 — AI 는 제안까지, **실행은 사용자**.
|
|
2426
|
+
*
|
|
2427
|
+
* 왜 카드인가: 되돌릴 수 없는 현장 명령을 문장 속 링크로 흘리면 실수로 눌린다. 무엇을·왜 하는지
|
|
2428
|
+
* 한 줄로 보이고 실행이 **분명한 한 번의 행동**이어야 한다.
|
|
2429
|
+
* 실행 결과는 호스트가 **대화에 기록**한다 — 누가 무엇을 실행했는지가 협의 이력에 남아야 한다.
|
|
2430
|
+
*/
|
|
2431
|
+
renderProposals(line) {
|
|
2432
|
+
const items = line.proposals ?? [];
|
|
2433
|
+
if (items.length === 0)
|
|
2434
|
+
return nothing;
|
|
2435
|
+
return html `
|
|
2436
|
+
<div class="proposals">
|
|
2437
|
+
${items.map(p => {
|
|
2438
|
+
const key = this.proposalKey(p);
|
|
2439
|
+
const sent = this.sentProposals.has(key);
|
|
2440
|
+
return html `
|
|
2441
|
+
<div class="proposal ${sent ? 'sent' : ''}">
|
|
2442
|
+
<md-icon>bolt</md-icon>
|
|
2443
|
+
<span class="pbody">
|
|
2444
|
+
<span class="plabel">${p.label || p.command}</span>
|
|
2445
|
+
${p.reason ? html `<span class="preason">${p.reason}</span>` : nothing}
|
|
2446
|
+
</span>
|
|
2447
|
+
<button
|
|
2448
|
+
class="run"
|
|
2449
|
+
?disabled=${sent}
|
|
2450
|
+
@click=${() => this.executeProposal(p, key)}>
|
|
2451
|
+
${sent ? i18next.t('board-ai.text.proposal-sent', { defaultValue: '실행 요청됨' })
|
|
2452
|
+
: i18next.t('board-ai.button.run-proposal', { defaultValue: '실행' })}
|
|
2453
|
+
</button>
|
|
2454
|
+
</div>
|
|
2455
|
+
`;
|
|
2456
|
+
})}
|
|
2457
|
+
</div>
|
|
2458
|
+
`;
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* 제안 식별 키 — 같은 조치를 두 번 실행하지 않도록 **효과**(명령·대상·인자)로만 만든다.
|
|
2462
|
+
*
|
|
2463
|
+
* 인자는 **키 순서를 정렬**해 직렬화한다: 같은 인자를 다른 순서로 받았을 뿐인데 키가 달라지면
|
|
2464
|
+
* 한 카드를 실행한 뒤에도 다른 카드가 살아 있어 **같은 명령이 두 번 나간다**(실제로 그랬다).
|
|
2465
|
+
* 설명 문구(label·reason)는 키에 넣지 않는다 — 문구가 달라도 같은 조치다.
|
|
2466
|
+
*/
|
|
2467
|
+
proposalKey(p) {
|
|
2468
|
+
const stable = (v) => {
|
|
2469
|
+
if (v === null || typeof v !== 'object')
|
|
2470
|
+
return JSON.stringify(v ?? null);
|
|
2471
|
+
if (Array.isArray(v))
|
|
2472
|
+
return `[${v.map(stable).join(',')}]`;
|
|
2473
|
+
return `{${Object.keys(v)
|
|
2474
|
+
.sort()
|
|
2475
|
+
.map(k => `${JSON.stringify(k)}:${stable(v[k])}`)
|
|
2476
|
+
.join(',')}}`;
|
|
2477
|
+
};
|
|
2478
|
+
try {
|
|
2479
|
+
return `${p?.command ?? ''}|${p?.instanceId ?? ''}|${stable(p?.args)}`;
|
|
2480
|
+
}
|
|
2481
|
+
catch {
|
|
2482
|
+
return `${p?.command ?? ''}|${p?.instanceId ?? ''}`;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* 실행은 호스트가 한다 — 이 컴포넌트는 도메인(트윈 명령 채널)을 모른다.
|
|
2487
|
+
* 확인 대화·명령 전송·결과 기록은 호스트의 몫이고, 여기서는 중복 클릭만 막는다.
|
|
2488
|
+
*/
|
|
2489
|
+
executeProposal(p, key) {
|
|
2490
|
+
this.sentProposals = new Set(this.sentProposals).add(key);
|
|
2491
|
+
this.dispatchEvent(new CustomEvent('board-ai-proposal-execute', {
|
|
2492
|
+
detail: { proposal: p, sessionId: this.sessionId },
|
|
2493
|
+
bubbles: true,
|
|
2494
|
+
composed: true
|
|
2495
|
+
}));
|
|
2496
|
+
}
|
|
2497
|
+
/**
|
|
2498
|
+
* 접지 경고 — 답이 언급했지만 **근거에 없던** 대상을 알린다.
|
|
2499
|
+
*
|
|
2500
|
+
* 왜 답을 지우지 않는가: 판정은 식별자 대조라 확정이 아니다(예: 사용자가 화면에서 본 이름을
|
|
2501
|
+
* 대화 밖에서 알고 말한 경우). 그래서 답은 그대로 보여주고, 검증되지 않았다는 사실만 붙인다 —
|
|
2502
|
+
* 사용자가 그 대상을 실제 작업의 근거로 삼기 전에 알아야 하는 정보다.
|
|
2503
|
+
*/
|
|
2504
|
+
renderGroundingWarning(line) {
|
|
2505
|
+
const ids = line.groundingWarnings ?? [];
|
|
2506
|
+
if (ids.length === 0)
|
|
2507
|
+
return nothing;
|
|
2508
|
+
return html `
|
|
2509
|
+
<div class="grounding-warning" role="note">
|
|
2510
|
+
<md-icon>report</md-icon>
|
|
2511
|
+
<span>
|
|
2512
|
+
${i18next.t('board-ai.text.ungrounded-mention', {
|
|
2513
|
+
defaultValue: '확인되지 않은 대상을 언급했습니다 — 실제 데이터에 없습니다:'
|
|
2514
|
+
})}
|
|
2515
|
+
<span class="ids">${ids.join(', ')}</span>
|
|
2516
|
+
</span>
|
|
2517
|
+
</div>
|
|
2518
|
+
`;
|
|
2519
|
+
}
|
|
2520
|
+
/**
|
|
2521
|
+
* "AI 가 이런 도구를 사용했습니다" fold-able 박스.
|
|
2522
|
+
*
|
|
2523
|
+
* - 기본 닫힘 (toolUsagesOpen=false). 헤더 클릭 시 toggle.
|
|
2524
|
+
* - 펼쳤을 때 도구 별로 name + arguments + result + read/write 색상 배지.
|
|
2525
|
+
* - 디버그 / 신뢰도 향상용 — 사용자가 "왜 이렇게 답했지" 의문 가질 때 즉시 검증.
|
|
2526
|
+
*/
|
|
2527
|
+
renderToolUsages(idx, line) {
|
|
2528
|
+
const usages = line.toolUsages;
|
|
2529
|
+
if (!usages || usages.length === 0)
|
|
2530
|
+
return nothing;
|
|
2531
|
+
const open = !!line.toolUsagesOpen;
|
|
2532
|
+
const readCount = usages.filter(u => u.kind === 'read').length;
|
|
2533
|
+
const writeCount = usages.filter(u => u.kind === 'write').length;
|
|
2534
|
+
const toolsLabel = i18next.t('board-ai.text.tools-used', { defaultValue: '도구 사용' });
|
|
2535
|
+
/* 눈에 걸려야 하는 것을 머리줄에 올린다 — 거절·접힘이 있으면 펼치기 전에 보인다.
|
|
2536
|
+
* (이번 사고들이 전부 그것이었다: 인자 빠진 호출이 거절됐고, 중복 제안이 접혔다.) */
|
|
2537
|
+
const problems = usages.filter(u => u.outcome === 'rejected' || u.outcome === 'error').length;
|
|
2538
|
+
const folded = usages.filter(u => u.outcome === 'folded').length;
|
|
2539
|
+
return html `
|
|
2540
|
+
<div class="tool-usages ${open ? 'open' : ''} ${problems ? 'has-problem' : ''}">
|
|
2541
|
+
<button
|
|
2542
|
+
class="tool-usages-header"
|
|
2543
|
+
aria-expanded=${open}
|
|
2544
|
+
@click=${() => this.toggleToolUsages(idx)}>
|
|
2545
|
+
<md-icon class="tool-usages-icon">${open ? 'expand_less' : 'account_tree'}</md-icon>
|
|
2546
|
+
<span class="tool-usages-summary">
|
|
2547
|
+
<strong>${usages.length}</strong> ${toolsLabel}
|
|
2548
|
+
<span class="tool-usages-counts">
|
|
2549
|
+
· read ${readCount} · write ${writeCount}${problems
|
|
2550
|
+
? html ` · <span class="tu-flag bad">${i18next.t('board-ai.text.tool-rejected-count', {
|
|
2551
|
+
count: problems,
|
|
2552
|
+
defaultValue: '거절 {count}'
|
|
2553
|
+
})}</span>`
|
|
2554
|
+
: nothing}${folded
|
|
2555
|
+
? html ` · <span class="tu-flag">${i18next.t('board-ai.text.tool-folded-count', {
|
|
2556
|
+
count: folded,
|
|
2557
|
+
defaultValue: '접힘 {count}'
|
|
2558
|
+
})}</span>`
|
|
2559
|
+
: nothing}
|
|
2560
|
+
</span>
|
|
2561
|
+
</span>
|
|
2562
|
+
<md-icon class="tool-usages-chevron">${open ? 'expand_less' : 'expand_more'}</md-icon>
|
|
2563
|
+
</button>
|
|
2564
|
+
${open
|
|
2565
|
+
? html `
|
|
2566
|
+
<ol class="tool-usages-list">
|
|
2567
|
+
${usages.map((u, i) => this.renderToolStep(idx, u, i, usages[i - 1]))}
|
|
2568
|
+
</ol>
|
|
2569
|
+
`
|
|
2570
|
+
: nothing}
|
|
2571
|
+
</div>
|
|
2572
|
+
`;
|
|
2573
|
+
}
|
|
2574
|
+
/** 시스템 기록 펼치기/접기 — 곁줄이 길어도 대화가 가려지지 않게. */
|
|
2575
|
+
toggleSystemNote(idx) {
|
|
2576
|
+
const next = [...this.lines];
|
|
2577
|
+
const line = next[idx];
|
|
2578
|
+
if (!line)
|
|
2579
|
+
return;
|
|
2580
|
+
next[idx] = { ...line, systemOpen: !line.systemOpen };
|
|
2581
|
+
this.lines = next;
|
|
2582
|
+
}
|
|
2583
|
+
/**
|
|
2584
|
+
* 판단 과정의 한 단계 — 턴 구분 · 도구 · 배지 · **한 줄 요약**.
|
|
2585
|
+
*
|
|
2586
|
+
* 원본 JSON 을 처음부터 펼쳐 두면 눈이 흐려져 정작 중요한 것(무엇이 거절됐는지, 무엇이 접혔는지)이
|
|
2587
|
+
* 안 보인다. 한 줄로 줄이고 원본은 항목별로 눌러서 본다.
|
|
2588
|
+
*/
|
|
2589
|
+
renderToolStep(lineIdx, u, i, prev) {
|
|
2590
|
+
const outcome = u.outcome ?? 'ok';
|
|
2591
|
+
const openKey = `${lineIdx}:${i}`;
|
|
2592
|
+
const open = this.openToolSteps.has(openKey);
|
|
2593
|
+
/* 턴이 바뀌면 구분선 — "한 턴 안에서 정정했는지, 다음 턴에서 했는지" 가 진단의 절반이다. */
|
|
2594
|
+
const newTurn = typeof u.iter === 'number' && (!prev || prev.iter !== u.iter);
|
|
2595
|
+
return html `
|
|
2596
|
+
${newTurn
|
|
2597
|
+
? html `<li class="tu-turn">
|
|
2598
|
+
${i18next.t('board-ai.text.tool-turn', { n: (u.iter ?? 0) + 1, defaultValue: '{n}번째 판단' })}
|
|
2599
|
+
</li>`
|
|
2600
|
+
: nothing}
|
|
2601
|
+
<li class="tool-usage-item kind-${u.kind} outcome-${outcome}">
|
|
2602
|
+
<div class="tool-usage-head" @click=${() => this.toggleToolStep(openKey)}>
|
|
2603
|
+
<span class="tool-usage-step">${i + 1}.</span>
|
|
2604
|
+
<span class="tool-usage-name">${u.name}</span>
|
|
2605
|
+
<span class="tu-badge ${outcome}">${this.outcomeLabel(outcome)}</span>
|
|
2606
|
+
<span class="tu-line" title=${this.stepSummary(u)}>${this.stepSummary(u)}</span>
|
|
2607
|
+
<md-icon class="tu-more">${open ? 'expand_less' : 'expand_more'}</md-icon>
|
|
2608
|
+
</div>
|
|
2609
|
+
${open
|
|
2610
|
+
? html `
|
|
2611
|
+
${u.arguments && Object.keys(u.arguments).length > 0
|
|
2612
|
+
? html `<pre class="tool-usage-args">${this.formatJson(u.arguments)}</pre>`
|
|
2613
|
+
: nothing}
|
|
2614
|
+
<pre class="tool-usage-result">${this.formatJson(u.result)}</pre>
|
|
2615
|
+
`
|
|
2616
|
+
: nothing}
|
|
2617
|
+
</li>
|
|
2618
|
+
`;
|
|
2619
|
+
}
|
|
2620
|
+
/** 배지 문구 — 코드가 아니라 사람 말로. */
|
|
2621
|
+
outcomeLabel(outcome) {
|
|
2622
|
+
const map = {
|
|
2623
|
+
ok: ['board-ai.text.outcome-ok', '조회'],
|
|
2624
|
+
rejected: ['board-ai.text.outcome-rejected', '거절'],
|
|
2625
|
+
queued: ['board-ai.text.outcome-queued', '적용 예정'],
|
|
2626
|
+
proposed: ['board-ai.text.outcome-proposed', '제안'],
|
|
2627
|
+
folded: ['board-ai.text.outcome-folded', '중복 접힘'],
|
|
2628
|
+
error: ['board-ai.text.outcome-error', '오류']
|
|
2629
|
+
};
|
|
2630
|
+
const [key, fallback] = map[outcome] ?? map.ok;
|
|
2631
|
+
return i18next.t(key, { defaultValue: fallback });
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* 결과 한 줄 — 무엇을 얻었는지/왜 막혔는지만. 목록에서 훑어 읽을 수 있어야 한다.
|
|
2635
|
+
* 거절이면 사유를, 조회면 대표적인 개수를, 제안이면 무엇을 제안했는지.
|
|
2636
|
+
*/
|
|
2637
|
+
stepSummary(u) {
|
|
2638
|
+
const r = u?.result;
|
|
2639
|
+
if (!r || typeof r !== 'object')
|
|
2640
|
+
return typeof r === 'string' ? r.slice(0, 80) : '';
|
|
2641
|
+
if (u.outcome === 'rejected' || u.outcome === 'error') {
|
|
2642
|
+
return String(r.errorCode || r.error || r.issues?.[0]?.message || '').slice(0, 80);
|
|
2643
|
+
}
|
|
2644
|
+
if (u.outcome === 'proposed' || u.outcome === 'folded') {
|
|
2645
|
+
return [r.command, r.args && Object.keys(r.args).length ? this.formatJson(r.args) : '']
|
|
2646
|
+
.filter(Boolean)
|
|
2647
|
+
.join(' ')
|
|
2648
|
+
.slice(0, 80);
|
|
2649
|
+
}
|
|
2650
|
+
/* 조회 결과 — 배열 길이가 있는 필드를 골라 "무엇이 몇 개" 로 요약한다(전체 덤프 금지). */
|
|
2651
|
+
const counts = Object.keys(r)
|
|
2652
|
+
.filter(k => Array.isArray(r[k]) && r[k].length > 0)
|
|
2653
|
+
.slice(0, 2)
|
|
2654
|
+
.map(k => `${k} ${r[k].length}`);
|
|
2655
|
+
if (counts.length)
|
|
2656
|
+
return counts.join(' · ');
|
|
2657
|
+
const keys = Object.keys(r).slice(0, 4);
|
|
2658
|
+
return keys.map(k => (typeof r[k] === 'object' ? k : `${k} ${r[k]}`)).join(' · ').slice(0, 80);
|
|
2659
|
+
}
|
|
2660
|
+
toggleToolStep(key) {
|
|
2661
|
+
const next = new Set(this.openToolSteps);
|
|
2662
|
+
next.has(key) ? next.delete(key) : next.add(key);
|
|
2663
|
+
this.openToolSteps = next;
|
|
2664
|
+
}
|
|
2665
|
+
toggleToolUsages(idx) {
|
|
2666
|
+
const next = [...this.lines];
|
|
2667
|
+
const line = next[idx];
|
|
2668
|
+
if (!line)
|
|
2669
|
+
return;
|
|
2670
|
+
next[idx] = { ...line, toolUsagesOpen: !line.toolUsagesOpen };
|
|
2671
|
+
this.lines = next;
|
|
2672
|
+
}
|
|
2673
|
+
formatJson(value) {
|
|
2674
|
+
try {
|
|
2675
|
+
return JSON.stringify(value, null, 2);
|
|
2676
|
+
}
|
|
2677
|
+
catch {
|
|
2678
|
+
return String(value);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
// ── Per-message actions ────────────────────────────────────────
|
|
2682
|
+
async copyMessage(idx, content) {
|
|
2683
|
+
try {
|
|
2684
|
+
// 클립보드에는 marker 제거된 깨끗한 텍스트
|
|
2685
|
+
await navigator.clipboard.writeText(stripMentionRefids(content));
|
|
2686
|
+
this.copiedIdx = idx;
|
|
2687
|
+
this.showToast(i18next.t('board-ai.text.copied'));
|
|
2688
|
+
setTimeout(() => {
|
|
2689
|
+
if (this.copiedIdx === idx)
|
|
2690
|
+
this.copiedIdx = undefined;
|
|
2691
|
+
}, 1500);
|
|
2692
|
+
}
|
|
2693
|
+
catch {
|
|
2694
|
+
// clipboard 권한 없거나 비-secure context — 조용히 무시
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
showToast(message) {
|
|
2698
|
+
this.toastMessage = message;
|
|
2699
|
+
setTimeout(() => {
|
|
2700
|
+
if (this.toastMessage === message)
|
|
2701
|
+
this.toastMessage = undefined;
|
|
2702
|
+
}, 1500);
|
|
2703
|
+
}
|
|
2704
|
+
editUserMessage(idx, content) {
|
|
2705
|
+
// textarea 에는 marker 제거된 깨끗한 텍스트 — 사용자가 raw `{refid:N}` 보지 않게.
|
|
2706
|
+
// 다시 send 시 popup pickedMentions 기반으로 marker 재주입.
|
|
2707
|
+
const cleaned = stripMentionRefids(content);
|
|
2708
|
+
/* 남길 마지막 줄의 서버 id — 없으면(아직 영속 안 된 줄뿐) 지정하지 않는다(서버가 전부 유지). */
|
|
2709
|
+
this._truncateAfterId = this.lines
|
|
2710
|
+
.slice(0, idx)
|
|
2711
|
+
.reverse()
|
|
2712
|
+
.find(l => !!l.id)?.id;
|
|
2713
|
+
this.lines = this.lines.slice(0, idx);
|
|
2714
|
+
this.input = cleaned;
|
|
2715
|
+
requestAnimationFrame(() => {
|
|
2716
|
+
const ta = this.renderRoot.querySelector('textarea');
|
|
2717
|
+
ta?.focus();
|
|
2718
|
+
ta?.setSelectionRange(cleaned.length, cleaned.length);
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* AI 응답 재생성 — 해당 assistant 메시지 + 이후 제거하고, 직전 user 메시지를 다시 send.
|
|
2723
|
+
*/
|
|
2724
|
+
async regenerateAssistant(idx) {
|
|
2725
|
+
if (this.busy)
|
|
2726
|
+
return;
|
|
2727
|
+
// 직전 user 메시지 찾기
|
|
2728
|
+
let userIdx = idx - 1;
|
|
2729
|
+
while (userIdx >= 0 && this.lines[userIdx].role !== 'user')
|
|
2730
|
+
userIdx--;
|
|
2731
|
+
if (userIdx < 0)
|
|
2732
|
+
return;
|
|
2733
|
+
// textarea 에는 marker 제거된 깨끗한 본문. send() 가 본문을 다시 line 으로 push 하는데,
|
|
2734
|
+
// pickedMentions 는 직전 send 에서 비워졌으므로 새 line 에는 marker 없음 — 클릭 시
|
|
2735
|
+
// runtime fallback resolver 가 처리. (영속된 원본 user 메시지의 marker 는 그대로 유지)
|
|
2736
|
+
const userContent = stripMentionRefids(this.lines[userIdx].content);
|
|
2737
|
+
// assistant 메시지 + 이후 제거
|
|
2738
|
+
this.lines = this.lines.slice(0, idx);
|
|
2739
|
+
this.input = userContent;
|
|
2740
|
+
await this.send();
|
|
2741
|
+
}
|
|
2742
|
+
// ── Patch revert ───────────────────────────────────────────────
|
|
2743
|
+
onRevertClick(patchId) {
|
|
2744
|
+
if (this.revertedPatchIds.has(patchId))
|
|
2745
|
+
return;
|
|
2746
|
+
this.revertedPatchIds = new Set([...this.revertedPatchIds, patchId]);
|
|
2747
|
+
// 호스트 (board-modeller-page) 에게 revert 요청 — 호스트가 snapshot 복원 + 서버 mutation 호출
|
|
2748
|
+
this.dispatchEvent(new CustomEvent('board-edit-revert', {
|
|
2749
|
+
detail: { patchId },
|
|
2750
|
+
bubbles: true,
|
|
2751
|
+
composed: true
|
|
2752
|
+
}));
|
|
2753
|
+
}
|
|
2754
|
+
async send() {
|
|
2755
|
+
const text = this.input.trim();
|
|
2756
|
+
if (!text || this.busy)
|
|
2757
|
+
return;
|
|
2758
|
+
// pickedMentions / pickedUserMentions 에서 현재 텍스트에 실제 #/@ token 으로
|
|
2759
|
+
// 등장하는 것만 추출 — 지운 토큰 제외. inline 마커로 content 에 주입.
|
|
2760
|
+
const mentions = [];
|
|
2761
|
+
for (const [token, refid] of this.pickedMentions.entries()) {
|
|
2762
|
+
if (text.includes(`#${token}`))
|
|
2763
|
+
mentions.push({ token, refid });
|
|
2764
|
+
}
|
|
2765
|
+
const userMentions = [];
|
|
2766
|
+
for (const [token, userId] of this.pickedUserMentions.entries()) {
|
|
2767
|
+
if (text.includes(`@${token}`))
|
|
2768
|
+
userMentions.push({ token, userId });
|
|
2769
|
+
}
|
|
2770
|
+
let enrichedContent = injectMentionRefids(text, mentions);
|
|
2771
|
+
enrichedContent = injectMentionUserIds(enrichedContent, userMentions);
|
|
2772
|
+
// optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
|
|
2773
|
+
const userLocalId = `local-${++this._localSeq}-u`;
|
|
2774
|
+
const asstLocalId = `local-${++this._localSeq}-a`;
|
|
2775
|
+
this.lines = [
|
|
2776
|
+
...this.lines,
|
|
2777
|
+
{ role: 'user', content: enrichedContent, _localId: userLocalId, senderEmail: this._myEmail }
|
|
2778
|
+
];
|
|
2779
|
+
this.input = '';
|
|
2780
|
+
this.busy = true;
|
|
2781
|
+
this.errorMessage = undefined;
|
|
2782
|
+
// 가짜 pending 응답 표시 (UX 부드럽게)
|
|
2783
|
+
this.lines = [...this.lines, { role: 'assistant', content: '', pending: true, _localId: asstLocalId }];
|
|
2784
|
+
try {
|
|
2785
|
+
// LLM 으로 보낼 history — user/assistant 만 (system 은 백엔드가 자동 합류).
|
|
2786
|
+
// pending placeholder 와 system 은 제외 (index 비의존 — 방송 append 와 무관).
|
|
2787
|
+
const history = this.lines
|
|
2788
|
+
.filter(l => !l.pending && l._localId !== asstLocalId && l.role !== 'system')
|
|
2789
|
+
.map(l => ({ role: l.role, content: l.content }));
|
|
2790
|
+
// 라이브 보드 우선 — 호스트의 캔버스가 사용자 수작업 편집을 들고 있을 수 있음.
|
|
2791
|
+
// boardProvider 가 있으면 send 시점에 그것을 pull, 없으면 정적 currentBoard.
|
|
2792
|
+
const liveBoard = this.boardProvider ? this.boardProvider() : this.currentBoard;
|
|
2793
|
+
// mentions 는 위에서 이미 계산해 user line 에 thread — 그대로 mutation 입력으로 재사용.
|
|
2794
|
+
const result = await client.mutate({
|
|
2795
|
+
mutation: BOARD_AI_CHAT_MUTATION,
|
|
2796
|
+
variables: {
|
|
2797
|
+
input: buildChatMutationInput({
|
|
2798
|
+
sessionId: this.sessionId,
|
|
2799
|
+
history,
|
|
2800
|
+
liveBoard,
|
|
2801
|
+
scopes: this.scopes,
|
|
2802
|
+
truncateAfterMessageId: this._truncateAfterId,
|
|
2803
|
+
hostContext: this.hostContext,
|
|
2804
|
+
toolCategories: this.toolCategories,
|
|
2805
|
+
boardTools: this.boardTools,
|
|
2806
|
+
requireGroundingTools: this.requireGroundingTools,
|
|
2807
|
+
knownTypes: this.knownTypes,
|
|
2808
|
+
categories: this.categories,
|
|
2809
|
+
componentSchemas: this.componentSchemas,
|
|
2810
|
+
selectedRefids: this.selectedRefids ?? [],
|
|
2811
|
+
mentions
|
|
2812
|
+
})
|
|
2813
|
+
}
|
|
2814
|
+
});
|
|
2815
|
+
const out = result.data?.boardAIChat;
|
|
2816
|
+
if (!out)
|
|
2817
|
+
throw new Error(i18next.t('board-ai.text.empty-response'));
|
|
2818
|
+
/* 대화가 길어졌다는 서버 보고 — 닫지 않았다면 안내를 띄운다. */
|
|
2819
|
+
this.historyFolded = out.historyFolded ?? undefined;
|
|
2820
|
+
// 전송 성공 — picks 정리 (다음 메시지부터는 새 picks 만)
|
|
2821
|
+
this.pickedMentions.clear();
|
|
2822
|
+
this.pickedUserMentions.clear();
|
|
2823
|
+
// optimistic 라인을 서버 id 로 reconcile (방송 에코와 중복 방지, index 비의존).
|
|
2824
|
+
// 사용자 라인: enrich 된 본문 유지, id 만 부여. AI 라인: pending → 실제 응답.
|
|
2825
|
+
this._reconcileSentLine(userLocalId, out.userMessageId, {});
|
|
2826
|
+
this._reconcileSentLine(asstLocalId, out.assistantMessageId, {
|
|
2827
|
+
role: 'assistant',
|
|
2828
|
+
content: out.reply,
|
|
2829
|
+
patchSummary: out.patch?.summary,
|
|
2830
|
+
followUp: out.followUp || undefined,
|
|
2831
|
+
patchId: out.patchId || undefined,
|
|
2832
|
+
toolUsages: Array.isArray(out.toolUsages) ? out.toolUsages : undefined,
|
|
2833
|
+
groundingWarnings: Array.isArray(out.groundingWarnings) ? out.groundingWarnings : undefined,
|
|
2834
|
+
proposals: Array.isArray(out.proposals) ? out.proposals : undefined,
|
|
2835
|
+
pending: false
|
|
2836
|
+
});
|
|
2837
|
+
// 호스트로 patch 이벤트 전파
|
|
2838
|
+
if (out.patch) {
|
|
2839
|
+
this.dispatchEvent(new CustomEvent('board-edit-patch', {
|
|
2840
|
+
detail: {
|
|
2841
|
+
patch: out.patch,
|
|
2842
|
+
summary: out.patch.summary,
|
|
2843
|
+
confidence: out.patch.confidence,
|
|
2844
|
+
patchId: out.patchId,
|
|
2845
|
+
sessionId: out.sessionId
|
|
2846
|
+
},
|
|
2847
|
+
bubbles: true,
|
|
2848
|
+
composed: true
|
|
2849
|
+
}));
|
|
2850
|
+
}
|
|
2851
|
+
// 호스트로 ephemeral scene 조작 actions 전파 (selection / view / mode)
|
|
2852
|
+
if (Array.isArray(out.actions) && out.actions.length > 0) {
|
|
2853
|
+
this.dispatchEvent(new CustomEvent('board-action-execute', {
|
|
2854
|
+
detail: { actions: out.actions, sessionId: out.sessionId },
|
|
2855
|
+
bubbles: true,
|
|
2856
|
+
composed: true
|
|
2857
|
+
}));
|
|
2858
|
+
}
|
|
2859
|
+
if (out.followUp) {
|
|
2860
|
+
this.dispatchEvent(new CustomEvent('chat-followup', {
|
|
2861
|
+
detail: { question: out.followUp },
|
|
2862
|
+
bubbles: true,
|
|
2863
|
+
composed: true
|
|
2864
|
+
}));
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
catch (e) {
|
|
2868
|
+
// pending(AI) placeholder 만 제거 — 사용자 라인은 보존(재시도). index 비의존.
|
|
2869
|
+
this.lines = this.lines.filter(l => l._localId !== asstLocalId);
|
|
2870
|
+
this.errorMessage = e?.message ?? String(e);
|
|
2871
|
+
this.lastFailedInput = text;
|
|
2872
|
+
}
|
|
2873
|
+
finally {
|
|
2874
|
+
this.busy = false;
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
2878
|
+
__decorate([
|
|
2879
|
+
property({ type: String, attribute: 'session-id' }),
|
|
2880
|
+
__metadata("design:type", String)
|
|
2881
|
+
], OxBoardAIChat.prototype, "sessionId", void 0);
|
|
2882
|
+
__decorate([
|
|
2883
|
+
property({ type: Array, attribute: false }),
|
|
2884
|
+
__metadata("design:type", Array)
|
|
2885
|
+
], OxBoardAIChat.prototype, "sessions", void 0);
|
|
2886
|
+
__decorate([
|
|
2887
|
+
property({ type: Object }),
|
|
2888
|
+
__metadata("design:type", Object)
|
|
2889
|
+
], OxBoardAIChat.prototype, "currentBoard", void 0);
|
|
2890
|
+
__decorate([
|
|
2891
|
+
property({ attribute: false }),
|
|
2892
|
+
__metadata("design:type", Function)
|
|
2893
|
+
], OxBoardAIChat.prototype, "boardProvider", void 0);
|
|
2894
|
+
__decorate([
|
|
2895
|
+
property({ attribute: false }),
|
|
2896
|
+
__metadata("design:type", Function)
|
|
2897
|
+
], OxBoardAIChat.prototype, "searchProvider", void 0);
|
|
2898
|
+
__decorate([
|
|
2899
|
+
property({ attribute: false }),
|
|
2900
|
+
__metadata("design:type", Function)
|
|
2901
|
+
], OxBoardAIChat.prototype, "userProvider", void 0);
|
|
2902
|
+
__decorate([
|
|
2903
|
+
property({ type: Array, attribute: false }),
|
|
2904
|
+
__metadata("design:type", Array)
|
|
2905
|
+
], OxBoardAIChat.prototype, "selectedRefids", void 0);
|
|
2906
|
+
__decorate([
|
|
2907
|
+
property({ type: Array, attribute: false }),
|
|
2908
|
+
__metadata("design:type", Array)
|
|
2909
|
+
], OxBoardAIChat.prototype, "toolCategories", void 0);
|
|
2910
|
+
__decorate([
|
|
2911
|
+
property({ type: Object, attribute: false }),
|
|
2912
|
+
__metadata("design:type", Object)
|
|
2913
|
+
], OxBoardAIChat.prototype, "hostContext", void 0);
|
|
2914
|
+
__decorate([
|
|
2915
|
+
property({ type: Boolean, attribute: 'board-tools' }),
|
|
2916
|
+
__metadata("design:type", Object)
|
|
2917
|
+
], OxBoardAIChat.prototype, "boardTools", void 0);
|
|
2918
|
+
__decorate([
|
|
2919
|
+
property({ type: Boolean, attribute: 'require-grounding-tools' }),
|
|
2920
|
+
__metadata("design:type", Object)
|
|
2921
|
+
], OxBoardAIChat.prototype, "requireGroundingTools", void 0);
|
|
2922
|
+
__decorate([
|
|
2923
|
+
property({ type: Array }),
|
|
2924
|
+
__metadata("design:type", Array)
|
|
2925
|
+
], OxBoardAIChat.prototype, "scopes", void 0);
|
|
2926
|
+
__decorate([
|
|
2927
|
+
property({ type: Array, attribute: 'known-types' }),
|
|
2928
|
+
__metadata("design:type", Array)
|
|
2929
|
+
], OxBoardAIChat.prototype, "knownTypes", void 0);
|
|
2930
|
+
__decorate([
|
|
2931
|
+
property({ type: Array }),
|
|
2932
|
+
__metadata("design:type", Array)
|
|
2933
|
+
], OxBoardAIChat.prototype, "categories", void 0);
|
|
2934
|
+
__decorate([
|
|
2935
|
+
property({ type: Array, attribute: false }),
|
|
2936
|
+
__metadata("design:type", Array)
|
|
2937
|
+
], OxBoardAIChat.prototype, "componentSchemas", void 0);
|
|
2938
|
+
__decorate([
|
|
2939
|
+
property({ type: String }),
|
|
2940
|
+
__metadata("design:type", Object)
|
|
2941
|
+
], OxBoardAIChat.prototype, "placeholder", void 0);
|
|
2942
|
+
__decorate([
|
|
2943
|
+
state(),
|
|
2944
|
+
__metadata("design:type", Array)
|
|
2945
|
+
], OxBoardAIChat.prototype, "lines", void 0);
|
|
2946
|
+
__decorate([
|
|
2947
|
+
state(),
|
|
2948
|
+
__metadata("design:type", Object)
|
|
2949
|
+
], OxBoardAIChat.prototype, "input", void 0);
|
|
2950
|
+
__decorate([
|
|
2951
|
+
property({ attribute: false }),
|
|
2952
|
+
__metadata("design:type", String)
|
|
2953
|
+
], OxBoardAIChat.prototype, "autoAsk", void 0);
|
|
2954
|
+
__decorate([
|
|
2955
|
+
property({ type: Object, attribute: false }),
|
|
2956
|
+
__metadata("design:type", Object)
|
|
2957
|
+
], OxBoardAIChat.prototype, "intro", void 0);
|
|
2958
|
+
__decorate([
|
|
2959
|
+
property({ type: Array, attribute: false }),
|
|
2960
|
+
__metadata("design:type", Array)
|
|
2961
|
+
], OxBoardAIChat.prototype, "examples", void 0);
|
|
2962
|
+
__decorate([
|
|
2963
|
+
state(),
|
|
2964
|
+
__metadata("design:type", String)
|
|
2965
|
+
], OxBoardAIChat.prototype, "lastFailedInput", void 0);
|
|
2966
|
+
__decorate([
|
|
2967
|
+
state(),
|
|
2968
|
+
__metadata("design:type", Object)
|
|
2969
|
+
], OxBoardAIChat.prototype, "revertedPatchIds", void 0);
|
|
2970
|
+
__decorate([
|
|
2971
|
+
state(),
|
|
2972
|
+
__metadata("design:type", Object)
|
|
2973
|
+
], OxBoardAIChat.prototype, "examplesOpen", void 0);
|
|
2974
|
+
__decorate([
|
|
2975
|
+
state(),
|
|
2976
|
+
__metadata("design:type", String)
|
|
2977
|
+
], OxBoardAIChat.prototype, "toastMessage", void 0);
|
|
2978
|
+
__decorate([
|
|
2979
|
+
state(),
|
|
2980
|
+
__metadata("design:type", Number)
|
|
2981
|
+
], OxBoardAIChat.prototype, "copiedIdx", void 0);
|
|
2982
|
+
__decorate([
|
|
2983
|
+
state(),
|
|
2984
|
+
__metadata("design:type", Object)
|
|
2985
|
+
], OxBoardAIChat.prototype, "openToolSteps", void 0);
|
|
2986
|
+
__decorate([
|
|
2987
|
+
state(),
|
|
2988
|
+
__metadata("design:type", Object)
|
|
2989
|
+
], OxBoardAIChat.prototype, "sentProposals", void 0);
|
|
2990
|
+
__decorate([
|
|
2991
|
+
state(),
|
|
2992
|
+
__metadata("design:type", Object)
|
|
2993
|
+
], OxBoardAIChat.prototype, "historyFolded", void 0);
|
|
2994
|
+
__decorate([
|
|
2995
|
+
state(),
|
|
2996
|
+
__metadata("design:type", Object)
|
|
2997
|
+
], OxBoardAIChat.prototype, "foldNoticeDismissed", void 0);
|
|
2998
|
+
__decorate([
|
|
2999
|
+
state(),
|
|
3000
|
+
__metadata("design:type", Object)
|
|
3001
|
+
], OxBoardAIChat.prototype, "busy", void 0);
|
|
3002
|
+
__decorate([
|
|
3003
|
+
state(),
|
|
3004
|
+
__metadata("design:type", String)
|
|
3005
|
+
], OxBoardAIChat.prototype, "errorMessage", void 0);
|
|
3006
|
+
__decorate([
|
|
3007
|
+
state(),
|
|
3008
|
+
__metadata("design:type", Array)
|
|
3009
|
+
], OxBoardAIChat.prototype, "participants", void 0);
|
|
3010
|
+
__decorate([
|
|
3011
|
+
state(),
|
|
3012
|
+
__metadata("design:type", Object)
|
|
3013
|
+
], OxBoardAIChat.prototype, "_presenceTick", void 0);
|
|
3014
|
+
__decorate([
|
|
3015
|
+
state(),
|
|
3016
|
+
__metadata("design:type", Object)
|
|
3017
|
+
], OxBoardAIChat.prototype, "mentionOpen", void 0);
|
|
3018
|
+
__decorate([
|
|
3019
|
+
state(),
|
|
3020
|
+
__metadata("design:type", Array)
|
|
3021
|
+
], OxBoardAIChat.prototype, "mentionResults", void 0);
|
|
3022
|
+
__decorate([
|
|
3023
|
+
state(),
|
|
3024
|
+
__metadata("design:type", Object)
|
|
3025
|
+
], OxBoardAIChat.prototype, "mentionActiveIndex", void 0);
|
|
3026
|
+
__decorate([
|
|
3027
|
+
state(),
|
|
3028
|
+
__metadata("design:type", Object)
|
|
3029
|
+
], OxBoardAIChat.prototype, "mentionOverflow", void 0);
|
|
3030
|
+
OxBoardAIChat = OxBoardAIChat_1 = __decorate([
|
|
3031
|
+
customElement('ox-board-ai-chat')
|
|
3032
|
+
], OxBoardAIChat);
|
|
3033
|
+
export { OxBoardAIChat };
|
|
3034
|
+
//# sourceMappingURL=board-ai-chat.js.map
|