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