@fcg-labs/cx-agent-hook 0.2.3 → 0.3.0

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/session.js ADDED
@@ -0,0 +1,349 @@
1
+ /**
2
+ * InquirySession — 문의 1건의 AI 초안 수명주기 (0.3.0 객체화의 핵).
3
+ *
4
+ * **초안의 정본은 세션 버퍼다.** compose 스트림은 UI 가 아니라 버퍼에
5
+ * 쓰고, UI 는 붙였다 뗐다 하는 sink 다 (`attachUi`/`detachUi`). 이 분리가
6
+ * 0.2.x 의 두 실결함을 구조의 귀결로 없앤다:
7
+ * ① 메뉴 이탈 → 복귀 시 초안 소실 (초안이 컴포넌트 로컬 상태였다)
8
+ * — 이제 이탈은 detachUi 일 뿐, 스트림은 버퍼로 완주하고 복귀는
9
+ * restore() 한 줄이다.
10
+ * ② 스트림 중 문의 전환 시 이전 문의 텍스트가 새 에디터를 오염
11
+ * — sink 를 뗀 스트림은 화면에 닿을 길 자체가 없다.
12
+ *
13
+ * 상태기계: idle → composing → drafted | declined.
14
+ * consume 규칙은 0.2.x 승계 — sent·discarded 가 그 제안의 마지막 판단
15
+ * (기록을 비운다), scored·edited 는 발송 전 여러 번 가능.
16
+ *
17
+ * 영속(sessionStorage): externalId 가 있는 세션만. TTL·LRU 캡을 넘긴
18
+ * 퇴거는 `discarded(note:"session_expired")` 로 원장에 남긴다 — 침묵
19
+ * 소거 금지 원칙의 연장. consume 시 즉시 삭제 (발송된 초안을 브라우저에
20
+ * 남기지 않는다 — 프라이버시 경계).
21
+ */
22
+
23
+ const NOT_CONFIGURED = () => ({
24
+ ok: false, answered: false, answer: "", answerId: null,
25
+ evidence: [], declinedReason: "not_configured", raw: {},
26
+ });
27
+
28
+ /** 세션 영속 저장소 — TTL·LRU 캡·퇴거 통지. sessionStorage 부재(SSR·Node)면
29
+ * 전부 무동작 (세션은 메모리로만 산다). */
30
+ export class SessionStore {
31
+ constructor({ impl, prefix, ttlMs, maxSessions, onEvict } = {}) {
32
+ this.impl = impl !== undefined
33
+ ? impl
34
+ : (typeof sessionStorage !== "undefined" ? sessionStorage : null);
35
+ this.prefix = prefix || "cx-agent-session";
36
+ this.ttlMs = ttlMs ?? 30 * 60 * 1000;
37
+ this.maxSessions = maxSessions ?? 20;
38
+ this.onEvict = onEvict || (() => {});
39
+ }
40
+
41
+ _key(id) { return `${this.prefix}:${id}`; }
42
+
43
+ load(id) {
44
+ if (!this.impl) return null;
45
+ try {
46
+ const rec = JSON.parse(this.impl.getItem(this._key(id)) || "null");
47
+ if (!rec) return null;
48
+ if (Date.now() - rec.at > this.ttlMs) {
49
+ this.remove(id);
50
+ this.onEvict(rec, "session_expired");
51
+ return null;
52
+ }
53
+ return rec;
54
+ } catch { return null; }
55
+ }
56
+
57
+ save(id, rec) {
58
+ if (!this.impl) return;
59
+ try {
60
+ this.impl.setItem(this._key(id), JSON.stringify({ ...rec, at: Date.now() }));
61
+ this._touchIndex(id);
62
+ } catch { /* 용량 초과 등 — 영속 실패가 세션을 막지 않는다 */ }
63
+ }
64
+
65
+ remove(id) {
66
+ if (!this.impl) return;
67
+ try {
68
+ this.impl.removeItem(this._key(id));
69
+ const idx = this._index().filter((x) => x !== id);
70
+ this.impl.setItem(`${this.prefix}:index`, JSON.stringify(idx));
71
+ } catch { /* 무시 */ }
72
+ }
73
+
74
+ _index() {
75
+ try {
76
+ return JSON.parse(this.impl.getItem(`${this.prefix}:index`) || "[]");
77
+ } catch { return []; }
78
+ }
79
+
80
+ _touchIndex(id) {
81
+ // LRU: 최근 사용을 꼬리로 — 캡 초과 시 머리(가장 오래됨)부터 퇴거
82
+ const idx = this._index().filter((x) => x !== id);
83
+ idx.push(id);
84
+ while (idx.length > this.maxSessions) {
85
+ const evictId = idx.shift();
86
+ try {
87
+ const rec = JSON.parse(this.impl.getItem(this._key(evictId)) || "null");
88
+ this.impl.removeItem(this._key(evictId));
89
+ if (rec) this.onEvict(rec, "session_evicted");
90
+ } catch { /* 무시 */ }
91
+ }
92
+ try {
93
+ this.impl.setItem(`${this.prefix}:index`, JSON.stringify(idx));
94
+ } catch { /* 무시 */ }
95
+ }
96
+ }
97
+
98
+ export class InquirySession {
99
+ /**
100
+ * 직접 만들지 않는다 — `agent.session(externalId)` 가 만든다.
101
+ * @param {object} deps agent 가 닫아 넣는 내부 의존 (전송·문구·데코레이터)
102
+ */
103
+ constructor(externalId, deps) {
104
+ this._id = externalId == null ? "" : String(externalId);
105
+ this._d = deps; // {client, messages, decorate, store, onError}
106
+ this._ui = null;
107
+ this._state = "idle";
108
+ this._draft = "";
109
+ this._statusText = "";
110
+ this._answerId = null;
111
+ this._composeSeq = 0;
112
+
113
+ // 영속 복원 — TTL 만료분은 store 가 이미 걸렀다
114
+ if (this._id && deps.store) {
115
+ const rec = deps.store.load(this._id);
116
+ if (rec) {
117
+ this._draft = rec.draft || "";
118
+ this._answerId = rec.answerId ?? null;
119
+ this._state = rec.state === "declined" ? "declined"
120
+ : (this._draft || this._answerId ? "drafted" : "idle");
121
+ }
122
+ }
123
+ }
124
+
125
+ get externalId() { return this._id; }
126
+ get state() { return this._state; }
127
+ get draft() { return this._draft; }
128
+ get adoptedAnswerId() { return this._answerId; }
129
+
130
+ /** UI sink 연결 — 초안을 자동 주입하지 않는다 (복원은 restore() 명시 호출).
131
+ * @param {{getDraft?:()=>string, setDraft:(t:string)=>void,
132
+ * setStatus?:(t:string)=>void}} ui */
133
+ attachUi(ui) {
134
+ this._ui = ui && typeof ui.setDraft === "function" ? ui : null;
135
+ return this;
136
+ }
137
+
138
+ /** UI 분리 — 진행 중 스트림은 버퍼로 계속 완주한다 (중단이 아니다). */
139
+ detachUi() {
140
+ this._ui = null;
141
+ return this;
142
+ }
143
+
144
+ /** 세션 정본을 UI 로 — 복귀 시 한 줄. UI 없으면 값만 돌려준다. */
145
+ restore() {
146
+ if (this._ui) {
147
+ this._ui.setDraft(this._draft);
148
+ if (this._ui.setStatus) this._ui.setStatus(this._statusText);
149
+ }
150
+ return { draft: this._draft, answerId: this._answerId, state: this._state };
151
+ }
152
+
153
+ /** 상담사 편집을 정본에 반영 (에디터 onChange → 스로틀은 소비처 몫). */
154
+ remember(text) {
155
+ this._draft = String(text ?? "");
156
+ if (this._state === "idle" && this._draft) this._state = "drafted";
157
+ this._persist();
158
+ }
159
+
160
+ _persist() {
161
+ if (this._id && this._d.store) {
162
+ this._d.store.save(this._id, {
163
+ draft: this._draft, answerId: this._answerId, state: this._state,
164
+ });
165
+ }
166
+ }
167
+
168
+ _clearPersist() {
169
+ if (this._id && this._d.store) this._d.store.remove(this._id);
170
+ }
171
+
172
+ _setUiDraft(text) { if (this._ui) this._ui.setDraft(text); }
173
+
174
+ _setStatus(text) {
175
+ this._statusText = text || "";
176
+ if (this._ui && this._ui.setStatus) this._ui.setStatus(this._statusText);
177
+ }
178
+
179
+ /**
180
+ * AI 초안 생성 — 스트림은 세션 버퍼에 쌓이고 UI 는 sink 로만 받는다.
181
+ * 의미론은 0.2.x composeDraft 와 동일 (자동 채택·교체 기각·실패 원복·
182
+ * abort 무간섭) + UI 분리에서도 성립.
183
+ *
184
+ * idleTimeoutMs/overallTimeoutMs: 스트림 시한 관통 — 큰 컨텍스트 창
185
+ * 운영(프리필 분 단위)은 서버 사다리와 함께 이 값도 올린다. 미지정이면
186
+ * 전송 계층 기본(90s/190s).
187
+ *
188
+ * @returns {{promise: Promise<object>, abort: ()=>void}}
189
+ */
190
+ compose({ inquiry, context, customerName, confirmOverwrite,
191
+ idleTimeoutMs, overallTimeoutMs } = {}) {
192
+ const d = this._d;
193
+ const noop = { promise: Promise.resolve(NOT_CONFIGURED()), abort: () => {} };
194
+ if (!d.client || !inquiry) {
195
+ this._setStatus(d.textOf("not_configured"));
196
+ return noop;
197
+ }
198
+ // "기존 초안" 의 정본: UI 가 붙어 있으면 에디터의 지금 값 (상담사가
199
+ // 마지막으로 본 것), 아니면 세션 버퍼.
200
+ const existing = this._ui && typeof this._ui.getDraft === "function"
201
+ ? String(this._ui.getDraft() || "")
202
+ : this._draft;
203
+ if (existing.trim() && !(confirmOverwrite && confirmOverwrite())) {
204
+ // 상담사가 쓰던 초안이 우선한다 — 조용히 덮지 않는다.
205
+ return { promise: Promise.resolve({ ...NOT_CONFIGURED(), declinedReason: "" }),
206
+ abort: () => {} };
207
+ }
208
+ // 새 스트림 = 이전 채택의 교체 시도 — 확정 전까지 보류 (0.2.3 계약)
209
+ const supersededId = this._answerId;
210
+ const supersededDraft = existing;
211
+ this._answerId = null;
212
+ const seq = ++this._composeSeq;
213
+ const stale = () => seq !== this._composeSeq;
214
+
215
+ const name = String(customerName || "").trim();
216
+ const contextWithName = name
217
+ ? { ...(context || {}), customerName: name }
218
+ : context;
219
+ const decorate = (text) => d.decorate(text, { customerName: name });
220
+
221
+ const controller = new AbortController();
222
+ let aborted = false;
223
+ let accumulated = "";
224
+ let pending = null;
225
+ const flushDraft = () => {
226
+ pending = null;
227
+ this._draft = decorate(accumulated);
228
+ this._setUiDraft(this._draft);
229
+ };
230
+ const queueDraft = () => {
231
+ // trailing 스로틀 — 청크마다 리렌더하면 큰 화면이 버벅인다
232
+ if (pending === null) pending = setTimeout(flushDraft, 80);
233
+ };
234
+ const clearPending = () => {
235
+ if (pending !== null) { clearTimeout(pending); pending = null; }
236
+ };
237
+
238
+ this._state = "composing";
239
+ this._setStatus(d.messages.ui_requesting);
240
+ this._draft = "";
241
+ this._setUiDraft("");
242
+
243
+ const promise = d.client.getAnswerStream(inquiry, contextWithName, {
244
+ signal: controller.signal,
245
+ ...(idleTimeoutMs ? { idleTimeoutMs } : {}),
246
+ ...(overallTimeoutMs ? { overallTimeoutMs } : {}),
247
+ onDelta: (text) => { accumulated += text; queueDraft(); },
248
+ onRestart: () => {
249
+ // 계약 위반 교정 — 지금까지 보인 초안은 폐기본이다
250
+ clearPending();
251
+ accumulated = "";
252
+ this._draft = "";
253
+ this._setUiDraft("");
254
+ this._setStatus(d.messages.ui_correcting);
255
+ },
256
+ onStage: (phase) => {
257
+ if (phase === "generating") this._setStatus(d.messages.ui_requesting);
258
+ },
259
+ }).then((result) => {
260
+ clearPending();
261
+ if (aborted || stale()) {
262
+ // 소비자 주도 중단(또는 뒤이은 compose 가 추월) — 화면·정본을 만지지
263
+ // 않는다. 0.2.3: 이전 문의 초안이 새 에디터에 주입되던 오염 경로 차단.
264
+ return result;
265
+ }
266
+ if (result.answered) {
267
+ this._draft = decorate(result.answer); // 정본 확정 (strip+데코레이터)
268
+ this._setUiDraft(this._draft);
269
+ const newId = result.answerId ?? null;
270
+ if (supersededId && supersededId !== newId) {
271
+ // 교체 확정 — 이전 제안은 기각으로 원장에 남긴다 (침묵 소거 금지)
272
+ d.client.discarded(supersededId, "", "redraft");
273
+ }
274
+ this._answerId = newId;
275
+ this._state = "drafted";
276
+ this._setStatus("");
277
+ this._persist();
278
+ } else {
279
+ // 실패·거절 — 미완성 텍스트를 정본·화면에 남기지 않는다.
280
+ // 채택 기록도 함께 원복 (0.2.3 비대칭 수정 승계).
281
+ this._draft = supersededDraft;
282
+ this._answerId = supersededId;
283
+ this._state = supersededDraft || supersededId ? "drafted" : "declined";
284
+ this._setUiDraft(supersededDraft);
285
+ this._setStatus(d.textOf(result.declinedReason || "unknown"));
286
+ this._persist();
287
+ }
288
+ return result;
289
+ });
290
+ return {
291
+ promise,
292
+ abort: () => {
293
+ // 중단 = 화면·정본은 그대로, 원장 상태는 요청 전으로 복귀
294
+ aborted = true;
295
+ this._answerId = supersededId;
296
+ this._draft = supersededDraft;
297
+ this._state = supersededDraft || supersededId ? "drafted" : "idle";
298
+ clearPending();
299
+ controller.abort();
300
+ },
301
+ };
302
+ }
303
+
304
+ /** 제안을 에디터에 넣었다 (패널 채택 버튼) */
305
+ noteAdopted(answerId) {
306
+ this._answerId = answerId || null;
307
+ if (this._answerId) this._state = "drafted";
308
+ this._persist();
309
+ }
310
+
311
+ /** 채택 기록 폐기 — 원장 통지 없는 로컬 클리어 (0.2.x clearAdopted) */
312
+ clearAdopted() {
313
+ this._answerId = null;
314
+ this._persist();
315
+ }
316
+
317
+ _emit(send, consume) {
318
+ const id = this._answerId;
319
+ if (consume) {
320
+ this._answerId = null;
321
+ this._draft = "";
322
+ this._statusText = "";
323
+ this._state = "idle";
324
+ this._clearPersist(); // 발송·폐기된 초안을 브라우저에 남기지 않는다
325
+ }
326
+ if (!this._d.client || !id) return;
327
+ send(id);
328
+ }
329
+
330
+ /** 발송 — 채택본이었다면 gold 쌍 후킹, 마지막 판단이라 기록을 비운다 */
331
+ answerSent(finalText, agent) {
332
+ this._emit((id) => this._d.client.sent(id, finalText, agent), true);
333
+ }
334
+
335
+ /** 품질 점수 1~5 — 발송 전 여러 번 가능 (비소모) */
336
+ scored(score, agent) {
337
+ this._emit((id) => this._d.client.scored(id, score, agent), false);
338
+ }
339
+
340
+ /** 수정 — 초안↔최종본 델타 (비소모) */
341
+ edited(finalText, agent) {
342
+ this._emit((id) => this._d.client.edited(id, finalText, agent), false);
343
+ }
344
+
345
+ /** 폐기 + 이유 — 마지막 판단이라 기록을 비운다 */
346
+ discarded(agent, note) {
347
+ this._emit((id) => this._d.client.discarded(id, agent, note), true);
348
+ }
349
+ }
package/styles.css CHANGED
@@ -1,40 +1,104 @@
1
- /* AI 답변 제안 패널 기본 스타일.
1
+ /* AI 답변 제안 패널 기본 스타일 (0.3.0 — 고객 제어권 전면 개방).
2
2
  *
3
3
  * 클래스는 `fcx-` 로 시작한다 — 소비처(고객사 관리자 화면)의 이름 공간과
4
- * 겹치지 않게 하기 위해서다. 배치(margin·위치)는 여기서 정하지 않고 소비처가
5
- * `className` 으로 준다. 색·간격을 바꾸려면 아래 변수만 덮어쓰면 된다.
4
+ * 겹치지 않게. 배치(margin·위치)는 소비처가 `className` 으로 준다.
5
+ *
6
+ * 테마 계약: 모든 값은 `var(--fcx-*, 폴백)` **사용처 직참조**다 — 컴포넌트가
7
+ * 토큰을 자체 선언하지 않으므로 소비처는 `:root { --fcx-accent: … }` 한 줄로
8
+ * 전체 팔레트를 바꾼다 (0.2.x 는 `.fcx-ai` 가 토큰을 선언해 :root 를 가렸다).
9
+ * 다크는 조상 어디든 `[data-fcx-theme="dark"]` 를 달면 켜진다.
10
+ *
11
+ * headless 2단계:
12
+ * ① 스타일 0 — 이 파일을 import 하지 않으면 마크업만 남는다 (클래스 유지)
13
+ * ② UI 0 — 어댑터 대신 `aiSuggestTree`/세션 표면만 쓰면 DOM 도 소비처 소유
6
14
  */
7
15
  .fcx-ai {
8
- --fcx-accent: #2f6fed;
9
- --fcx-surface: #f5f9ff;
10
- --fcx-border: #d7e3f4;
11
- --fcx-text: #1f2d3d;
12
- --fcx-muted: #5b6b7e;
13
- --fcx-warn: #8a5a00;
14
-
15
- padding: 10px 12px;
16
- border: 1px solid var(--fcx-border);
17
- border-radius: 10px;
18
- background: var(--fcx-surface);
16
+ padding: var(--fcx-pad, 10px 12px);
17
+ border: 1px solid var(--fcx-border, #d7e3f4);
18
+ border-radius: var(--fcx-radius, 10px);
19
+ background: var(--fcx-surface, #f5f9ff);
20
+ font-size: var(--fcx-font-size, 13px);
21
+ color: var(--fcx-text, #1f2d3d);
19
22
  }
20
23
 
21
- .fcx-ai-head { display: flex; gap: 8px; align-items: center; }
24
+ .fcx-ai-head { display: flex; gap: var(--fcx-gap, 8px); align-items: center; }
22
25
 
23
26
  .fcx-ai-button,
24
27
  .fcx-ai-adopt {
25
- padding: 6px 12px;
26
- border: 1px solid var(--fcx-accent);
27
- border-radius: 8px;
28
- font-size: 12px;
29
- font-weight: 600;
28
+ padding: var(--fcx-button-pad, 6px 12px);
29
+ border: 1px solid var(--fcx-accent, #2f6fed);
30
+ border-radius: var(--fcx-button-radius, 8px);
31
+ font-size: var(--fcx-button-font-size, 12px);
32
+ font-weight: var(--fcx-button-weight, 600);
30
33
  cursor: pointer;
34
+ transition: background-color var(--fcx-motion, 150ms ease),
35
+ color var(--fcx-motion, 150ms ease),
36
+ box-shadow var(--fcx-motion, 150ms ease);
37
+ }
38
+
39
+ .fcx-ai-button {
40
+ background: var(--fcx-accent, #2f6fed);
41
+ color: var(--fcx-on-accent, #fff);
42
+ }
43
+ .fcx-ai-button:hover:not(:disabled) {
44
+ background: var(--fcx-accent-hover, #245cd0);
45
+ }
46
+ .fcx-ai-button:disabled {
47
+ opacity: var(--fcx-disabled-opacity, 0.6);
48
+ cursor: default;
49
+ }
50
+ .fcx-ai-adopt {
51
+ background: var(--fcx-adopt-surface, #fff);
52
+ color: var(--fcx-accent, #2f6fed);
53
+ }
54
+ .fcx-ai-adopt:hover {
55
+ background: var(--fcx-adopt-hover, #eef4ff);
56
+ }
57
+ .fcx-ai-button:focus-visible,
58
+ .fcx-ai-adopt:focus-visible {
59
+ outline: none;
60
+ box-shadow: 0 0 0 var(--fcx-focus-ring-width, 2px)
61
+ var(--fcx-focus-ring, rgba(47, 111, 237, 0.45));
62
+ }
63
+
64
+ .fcx-ai-body { margin-top: var(--fcx-gap, 8px); }
65
+ .fcx-ai-answer {
66
+ white-space: pre-wrap;
67
+ font-size: var(--fcx-font-size, 13px);
68
+ color: var(--fcx-text, #1f2d3d);
69
+ }
70
+ .fcx-ai-evidence {
71
+ margin-top: 6px;
72
+ font-size: var(--fcx-evidence-font-size, 11px);
73
+ color: var(--fcx-muted, #5b6b7e);
74
+ }
75
+ .fcx-ai-declined {
76
+ margin-top: var(--fcx-gap, 8px);
77
+ font-size: var(--fcx-button-font-size, 12px);
78
+ color: var(--fcx-warn, #8a5a00);
31
79
  }
32
80
 
33
- .fcx-ai-button { background: var(--fcx-accent); color: #fff; }
34
- .fcx-ai-button:disabled { opacity: 0.6; cursor: default; }
35
- .fcx-ai-adopt { background: #fff; color: var(--fcx-accent); }
81
+ /* 상태 모디파이어 — 소비처·어댑터가 붙일 수 있는 선택 훅 (마크업 불변) */
82
+ .fcx-ai--loading .fcx-ai-button { opacity: var(--fcx-disabled-opacity, 0.6); }
83
+ .fcx-ai--declined { border-color: var(--fcx-warn-border, #e8d5b0); }
36
84
 
37
- .fcx-ai-body { margin-top: 8px; }
38
- .fcx-ai-answer { white-space: pre-wrap; font-size: 13px; color: var(--fcx-text); }
39
- .fcx-ai-evidence { margin-top: 6px; font-size: 11px; color: var(--fcx-muted); }
40
- .fcx-ai-declined { margin-top: 8px; font-size: 12px; color: var(--fcx-warn); }
85
+ /* 다크 — 조상 [data-fcx-theme="dark"] 켠다. 토큰만 바꾸므로 소비처
86
+ * :root 오버라이드와 규칙이 같다 (더 구체적인 선언이 이긴다). */
87
+ [data-fcx-theme="dark"] .fcx-ai {
88
+ border-color: var(--fcx-border, #2b3a4f);
89
+ background: var(--fcx-surface, #16202e);
90
+ color: var(--fcx-text, #dbe6f3);
91
+ }
92
+ [data-fcx-theme="dark"] .fcx-ai-answer { color: var(--fcx-text, #dbe6f3); }
93
+ [data-fcx-theme="dark"] .fcx-ai-evidence { color: var(--fcx-muted, #8ba0b8); }
94
+ [data-fcx-theme="dark"] .fcx-ai-adopt {
95
+ background: var(--fcx-adopt-surface, #16202e);
96
+ }
97
+ [data-fcx-theme="dark"] .fcx-ai-adopt:hover {
98
+ background: var(--fcx-adopt-hover, #1e2c3f);
99
+ }
100
+ [data-fcx-theme="dark"] .fcx-ai-declined { color: var(--fcx-warn, #d9a94e); }
101
+
102
+ @media (prefers-reduced-motion: reduce) {
103
+ .fcx-ai-button, .fcx-ai-adopt { transition: none; }
104
+ }
package/view.d.ts CHANGED
@@ -25,3 +25,20 @@ export declare const CLS: Record<
25
25
  "root" | "head" | "button" | "adopt" | "body" | "answer" | "evidence" | "declined",
26
26
  string
27
27
  >;
28
+
29
+ /** 프레임워크 중립 트리 노드 (0.3.0) — 어댑터는 이 트리의 인터프리터다 */
30
+ export interface AiSuggestNode {
31
+ tag: string;
32
+ cls: string;
33
+ text?: string;
34
+ children?: AiSuggestNode[];
35
+ /** 추상 동작명 — 어댑터가 자기 이벤트 체계로 배선: "request" | "adopt" */
36
+ action?: "request" | "adopt";
37
+ type?: string;
38
+ disabled?: boolean;
39
+ }
40
+
41
+ /** 패널 전체 구조 스펙 — headless(UI 0) 소비처의 진입점이기도 하다 */
42
+ export declare function aiSuggestTree(
43
+ input: Parameters<typeof aiSuggestView>[0],
44
+ ): AiSuggestNode;
package/view.js CHANGED
@@ -58,3 +58,48 @@ export const CLS = {
58
58
  evidence: "fcx-ai-evidence",
59
59
  declined: "fcx-ai-declined",
60
60
  };
61
+
62
+ /**
63
+ * 패널 전체의 프레임워크 중립 트리 스펙 (0.3.0).
64
+ *
65
+ * aiSuggestView 가 "무엇을 보여줄지" 였다면 이것은 "어떤 구조로 그릴지"
66
+ * 까지다 — react/vue/element 는 이 트리의 15줄급 인터프리터로 줄어든다.
67
+ * 세 어댑터가 구조를 각자 복제하던 시절에는 하나를 고치면 나머지 둘이
68
+ * 그대로 남았다 (뷰 3중 복제).
69
+ *
70
+ * 노드: { tag, cls, text?, children?, action?, type?, disabled? }
71
+ * action 은 추상 동작명 — 어댑터가 자기 이벤트 체계로 배선한다:
72
+ * "request" 제안 요청 / "adopt" 에디터에 넣기
73
+ * 루트의 클릭 전파 차단(행 선택 토글 방지)은 어댑터 몫이다 — 트리는
74
+ * 구조만 말한다.
75
+ */
76
+ export function aiSuggestTree(input) {
77
+ const v = aiSuggestView(input);
78
+ const head = {
79
+ tag: "div", cls: CLS.head,
80
+ children: [
81
+ { tag: "button", cls: CLS.button, type: "button",
82
+ disabled: v.buttonDisabled, text: v.buttonLabel, action: "request" },
83
+ ...(v.showAdopt
84
+ ? [{ tag: "button", cls: CLS.adopt, type: "button",
85
+ disabled: false, text: v.adoptLabel, action: "adopt" }]
86
+ : []),
87
+ ],
88
+ };
89
+ const children = [head];
90
+ if (v.body.kind === "answer") {
91
+ children.push({
92
+ tag: "div", cls: CLS.body,
93
+ children: [
94
+ { tag: "div", cls: CLS.answer, text: v.body.text },
95
+ ...(v.body.evidence.length > 0
96
+ ? [{ tag: "div", cls: CLS.evidence,
97
+ text: `${v.body.evidenceLabel}: ${v.body.evidence.join(" · ")}` }]
98
+ : []),
99
+ ],
100
+ });
101
+ } else if (v.body.kind === "declined") {
102
+ children.push({ tag: "div", cls: CLS.declined, text: v.body.text });
103
+ }
104
+ return { tag: "div", cls: CLS.root, children };
105
+ }
package/vue.js CHANGED
@@ -21,10 +21,24 @@
21
21
  */
22
22
  import { computed, defineComponent, h, ref, watch } from "vue";
23
23
 
24
- import { aiSuggestView, CLS } from "./view.js";
24
+ import { aiSuggestTree, aiSuggestView, CLS } from "./view.js";
25
25
 
26
26
  export { aiSuggestView };
27
27
 
28
+ /** 트리 노드 → VNode — 구조는 view.aiSuggestTree 가 소유한다. */
29
+ function renderNode(node, actions) {
30
+ const props = { class: node.cls };
31
+ if (node.tag === "button") {
32
+ props.type = node.type || "button";
33
+ props.disabled = Boolean(node.disabled);
34
+ }
35
+ if (node.action && actions[node.action]) props.onClick = actions[node.action];
36
+ return h(
37
+ node.tag, props,
38
+ node.children ? node.children.map((c) => renderNode(c, actions)) : node.text,
39
+ );
40
+ }
41
+
28
42
  export const AiSuggestPanel = defineComponent({
29
43
  name: "AiSuggestPanel",
30
44
  props: {
@@ -68,8 +82,8 @@ export const AiSuggestPanel = defineComponent({
68
82
  emit("adopt", r.answer);
69
83
  };
70
84
 
71
- const view = computed(() =>
72
- aiSuggestView({
85
+ const tree = computed(() =>
86
+ aiSuggestTree({
73
87
  state: state.value,
74
88
  result: result.value,
75
89
  declineText: props.hook.declineText,
@@ -79,7 +93,6 @@ export const AiSuggestPanel = defineComponent({
79
93
 
80
94
  return () => {
81
95
  if (!props.hook || !props.hook.enabled) return null;
82
- const v = view.value;
83
96
  return h(
84
97
  "div",
85
98
  {
@@ -88,33 +101,7 @@ export const AiSuggestPanel = defineComponent({
88
101
  // 누르려다 행 선택이 토글되면 제안이 초기화된다.
89
102
  onClick: (event) => event.stopPropagation(),
90
103
  },
91
- [
92
- h("div", { class: CLS.head }, [
93
- h(
94
- "button",
95
- { type: "button", class: CLS.button, disabled: v.buttonDisabled, onClick: request },
96
- v.buttonLabel,
97
- ),
98
- v.showAdopt
99
- ? h("button", { type: "button", class: CLS.adopt, onClick: adopt }, v.adoptLabel)
100
- : null,
101
- ]),
102
- v.body.kind === "answer"
103
- ? h("div", { class: CLS.body }, [
104
- h("div", { class: CLS.answer }, v.body.text),
105
- v.body.evidence.length > 0
106
- ? h(
107
- "div",
108
- { class: CLS.evidence },
109
- `${v.body.evidenceLabel}: ${v.body.evidence.join(" · ")}`,
110
- )
111
- : null,
112
- ])
113
- : null,
114
- v.body.kind === "declined"
115
- ? h("div", { class: CLS.declined }, v.body.text)
116
- : null,
117
- ],
104
+ tree.value.children.map((node) => renderNode(node, { request, adopt })),
118
105
  );
119
106
  };
120
107
  },