@fcg-labs/cx-agent-hook 0.2.3 → 0.4.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,455 @@
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
+ /** 처리 액션 pending 저장소 — 세션 영속(_persist·TTL 30분·consume 삭제)과
24
+ * **수명이 다르다**: 요청은 전송 **전에** 남고, 종단 뒤 24h 까지 살아 새로고침·
25
+ * 재진입 시 "이미 눌렀던 것" 을 안다. 정본은 허브 원장이다 — 브라우저 소실은
26
+ * 기록 손실이 아니라 표시 손실일 뿐. localStorage 부재면 메모리로만 산다. */
27
+ export class ActionLedger {
28
+ constructor({ impl, prefix = "cx-agent-actions", terminalTtlMs = 24 * 60 * 60 * 1000 } = {}) {
29
+ this._impl = impl || (typeof localStorage !== "undefined" ? localStorage : null);
30
+ this._key = prefix;
31
+ this._ttl = terminalTtlMs;
32
+ this._mem = new Map();
33
+ }
34
+ _all() {
35
+ if (!this._impl) return this._mem;
36
+ try {
37
+ const raw = JSON.parse(this._impl.getItem(this._key) || "{}");
38
+ return new Map(Object.entries(raw));
39
+ } catch { return new Map(); }
40
+ }
41
+ _save(map) {
42
+ if (!this._impl) { this._mem = map; return; }
43
+ try { this._impl.setItem(this._key, JSON.stringify(Object.fromEntries(map))); } catch { /* quota */ }
44
+ }
45
+ put(requestId, rec) {
46
+ const m = this._all();
47
+ m.set(requestId, { ...rec, at: Date.now() });
48
+ this._save(this._prune(m));
49
+ }
50
+ get(requestId) { return this._all().get(requestId) || null; }
51
+ forInquiry(externalId) {
52
+ const out = [];
53
+ for (const [rid, r] of this._all()) if (r.externalId === externalId) out.push({ requestId: rid, ...r });
54
+ return out;
55
+ }
56
+ _prune(m) {
57
+ const now = Date.now();
58
+ for (const [rid, r] of m) {
59
+ if ((r.state === "succeeded" || r.state === "failed") && now - (r.at || 0) > this._ttl) m.delete(rid);
60
+ }
61
+ return m;
62
+ }
63
+ }
64
+
65
+ const NOT_CONFIGURED = () => ({
66
+ ok: false, answered: false, answer: "", answerId: null,
67
+ evidence: [], declinedReason: "not_configured", raw: {},
68
+ });
69
+
70
+ /** 세션 영속 저장소 — TTL·LRU 캡·퇴거 통지. sessionStorage 부재(SSR·Node)면
71
+ * 전부 무동작 (세션은 메모리로만 산다). */
72
+ export class SessionStore {
73
+ constructor({ impl, prefix, ttlMs, maxSessions, onEvict } = {}) {
74
+ this.impl = impl !== undefined
75
+ ? impl
76
+ : (typeof sessionStorage !== "undefined" ? sessionStorage : null);
77
+ this.prefix = prefix || "cx-agent-session";
78
+ this.ttlMs = ttlMs ?? 30 * 60 * 1000;
79
+ this.maxSessions = maxSessions ?? 20;
80
+ this.onEvict = onEvict || (() => {});
81
+ }
82
+
83
+ _key(id) { return `${this.prefix}:${id}`; }
84
+
85
+ load(id) {
86
+ if (!this.impl) return null;
87
+ try {
88
+ const rec = JSON.parse(this.impl.getItem(this._key(id)) || "null");
89
+ if (!rec) return null;
90
+ if (Date.now() - rec.at > this.ttlMs) {
91
+ this.remove(id);
92
+ this.onEvict(rec, "session_expired");
93
+ return null;
94
+ }
95
+ return rec;
96
+ } catch { return null; }
97
+ }
98
+
99
+ save(id, rec) {
100
+ if (!this.impl) return;
101
+ try {
102
+ this.impl.setItem(this._key(id), JSON.stringify({ ...rec, at: Date.now() }));
103
+ this._touchIndex(id);
104
+ } catch { /* 용량 초과 등 — 영속 실패가 세션을 막지 않는다 */ }
105
+ }
106
+
107
+ remove(id) {
108
+ if (!this.impl) return;
109
+ try {
110
+ this.impl.removeItem(this._key(id));
111
+ const idx = this._index().filter((x) => x !== id);
112
+ this.impl.setItem(`${this.prefix}:index`, JSON.stringify(idx));
113
+ } catch { /* 무시 */ }
114
+ }
115
+
116
+ _index() {
117
+ try {
118
+ return JSON.parse(this.impl.getItem(`${this.prefix}:index`) || "[]");
119
+ } catch { return []; }
120
+ }
121
+
122
+ _touchIndex(id) {
123
+ // LRU: 최근 사용을 꼬리로 — 캡 초과 시 머리(가장 오래됨)부터 퇴거
124
+ const idx = this._index().filter((x) => x !== id);
125
+ idx.push(id);
126
+ while (idx.length > this.maxSessions) {
127
+ const evictId = idx.shift();
128
+ try {
129
+ const rec = JSON.parse(this.impl.getItem(this._key(evictId)) || "null");
130
+ this.impl.removeItem(this._key(evictId));
131
+ if (rec) this.onEvict(rec, "session_evicted");
132
+ } catch { /* 무시 */ }
133
+ }
134
+ try {
135
+ this.impl.setItem(`${this.prefix}:index`, JSON.stringify(idx));
136
+ } catch { /* 무시 */ }
137
+ }
138
+ }
139
+
140
+ export class InquirySession {
141
+ /**
142
+ * 직접 만들지 않는다 — `agent.session(externalId)` 가 만든다.
143
+ * @param {object} deps agent 가 닫아 넣는 내부 의존 (전송·문구·데코레이터)
144
+ */
145
+ constructor(externalId, deps) {
146
+ this._id = externalId == null ? "" : String(externalId);
147
+ this._d = deps; // {client, messages, decorate, store, onError}
148
+ this._ui = null;
149
+ this._state = "idle";
150
+ this._draft = "";
151
+ this._statusText = "";
152
+ this._answerId = null;
153
+ this._composeSeq = 0;
154
+ this._offers = []; // 마지막 답변의 처리 선택지 (actions/1 §4)
155
+ this._actionUi = null;
156
+
157
+ // 영속 복원 — TTL 만료분은 store 가 이미 걸렀다
158
+ if (this._id && deps.store) {
159
+ const rec = deps.store.load(this._id);
160
+ if (rec) {
161
+ this._draft = rec.draft || "";
162
+ this._answerId = rec.answerId ?? null;
163
+ this._state = rec.state === "declined" ? "declined"
164
+ : (this._draft || this._answerId ? "drafted" : "idle");
165
+ }
166
+ }
167
+ }
168
+
169
+ get externalId() { return this._id; }
170
+ /** 처리 선택지 (만료 제외). 실행 여부는 pendingActions() 로. */
171
+ get offers() {
172
+ const now = Date.now();
173
+ return this._offers.filter((o) => !o.expires_at || Date.parse(o.expires_at) > now);
174
+ }
175
+ attachActionUi(ui) { this._actionUi = ui; this._notifyActions(); }
176
+ detachActionUi() { this._actionUi = null; }
177
+ _notifyActions() {
178
+ if (this._actionUi && typeof this._actionUi.setOffers === "function") {
179
+ try { this._actionUi.setOffers(this.offers, this.pendingActions()); } catch { /* UI 오류가 세션을 죽이지 않는다 */ }
180
+ }
181
+ }
182
+ /** 이 문의에서 눌렀던 처리들 (pending·unknown·종단 24h 내) — 정본은 허브 원장 */
183
+ pendingActions() {
184
+ const d = this._d;
185
+ return d.actionLedger ? d.actionLedger.forInquiry(this._id) : [];
186
+ }
187
+ /**
188
+ * 처리 실행 — 사람 확인은 호출자(UI)가 끝낸 뒤 부른다. request_id 를 발급해
189
+ * **전송 전에** 영속하고, 재시도 0. 네트워크 오류면 상태 조회 1회 → 404(미도달)
190
+ * 일 때만 같은 request_id 로 1회 재전송 (실행된 적 없음이 확정이라 안전).
191
+ */
192
+ async executeAction(offer, { actorClaimed } = {}) {
193
+ const d = this._d;
194
+ if (!d.client || !offer || !offer.offer_id) return { ok: false, state: "", error: "not_configured" };
195
+ if (typeof d.actorAssertion !== "function") return { ok: false, state: "", error: "actor_assertion_missing" };
196
+ let assertion = "";
197
+ try { assertion = String(await d.actorAssertion() || ""); } catch { assertion = ""; }
198
+ if (!assertion) return { ok: false, state: "", error: "actor_assertion_missing" };
199
+ const requestId = d.newRequestId();
200
+ const rec = { externalId: this._id, offerId: offer.offer_id, actionKey: offer.action_key,
201
+ label: offer.label || "", state: "pending" };
202
+ if (d.actionLedger) d.actionLedger.put(requestId, rec);
203
+ const args = { requestId, offer, actorAssertion: assertion, actorClaimed, inquiryRef: this._id };
204
+ let r = await d.client.requestAction(args);
205
+ if (!r.ok && r.error === "network_error") {
206
+ const st = await d.client.actionStatus(requestId);
207
+ if (st.ok && st.state === "none") r = await d.client.requestAction(args); // 미도달 확정 — 1회 재전송
208
+ else if (st.ok) r = { ...r, ok: true, state: st.state, reasonCode: st.reasonCode, message: st.message, error: null };
209
+ else r = { ...r, state: "unknown", error: "network_error" };
210
+ }
211
+ if (d.actionLedger) d.actionLedger.put(requestId, { ...rec, state: r.state || (r.ok ? "unknown" : "failed"),
212
+ reasonCode: r.reasonCode || "", message: r.message || "" });
213
+ this._notifyActions();
214
+ return { requestId, ...r };
215
+ }
216
+ /** 재조정 — lookup 만. 재실행 버튼은 존재하지 않는다. */
217
+ async reconcileAction(requestId) {
218
+ const d = this._d;
219
+ if (!d.client || !requestId) return { ok: false, state: "", error: "not_configured" };
220
+ const r = await d.client.actionReconcile(requestId);
221
+ if (d.actionLedger && r.ok) {
222
+ const prev = d.actionLedger.get(requestId) || { externalId: this._id };
223
+ d.actionLedger.put(requestId, { ...prev, state: r.state, reasonCode: r.reasonCode || "", message: r.message || "" });
224
+ }
225
+ this._notifyActions();
226
+ return r;
227
+ }
228
+ get state() { return this._state; }
229
+ get draft() { return this._draft; }
230
+ get adoptedAnswerId() { return this._answerId; }
231
+
232
+ /** UI sink 연결 — 초안을 자동 주입하지 않는다 (복원은 restore() 명시 호출).
233
+ * @param {{getDraft?:()=>string, setDraft:(t:string)=>void,
234
+ * setStatus?:(t:string)=>void}} ui */
235
+ attachUi(ui) {
236
+ this._ui = ui && typeof ui.setDraft === "function" ? ui : null;
237
+ return this;
238
+ }
239
+
240
+ /** UI 분리 — 진행 중 스트림은 버퍼로 계속 완주한다 (중단이 아니다). */
241
+ detachUi() {
242
+ this._ui = null;
243
+ return this;
244
+ }
245
+
246
+ /** 세션 정본을 UI 로 — 복귀 시 한 줄. UI 없으면 값만 돌려준다. */
247
+ restore() {
248
+ if (this._ui) {
249
+ this._ui.setDraft(this._draft);
250
+ if (this._ui.setStatus) this._ui.setStatus(this._statusText);
251
+ }
252
+ return { draft: this._draft, answerId: this._answerId, state: this._state };
253
+ }
254
+
255
+ /** 상담사 편집을 정본에 반영 (에디터 onChange → 스로틀은 소비처 몫). */
256
+ remember(text) {
257
+ this._draft = String(text ?? "");
258
+ if (this._state === "idle" && this._draft) this._state = "drafted";
259
+ this._persist();
260
+ }
261
+
262
+ _persist() {
263
+ if (this._id && this._d.store) {
264
+ this._d.store.save(this._id, {
265
+ draft: this._draft, answerId: this._answerId, state: this._state,
266
+ });
267
+ }
268
+ }
269
+
270
+ _clearPersist() {
271
+ if (this._id && this._d.store) this._d.store.remove(this._id);
272
+ }
273
+
274
+ _setUiDraft(text) { if (this._ui) this._ui.setDraft(text); }
275
+
276
+ _setStatus(text) {
277
+ this._statusText = text || "";
278
+ if (this._ui && this._ui.setStatus) this._ui.setStatus(this._statusText);
279
+ }
280
+
281
+ /**
282
+ * AI 초안 생성 — 스트림은 세션 버퍼에 쌓이고 UI 는 sink 로만 받는다.
283
+ * 의미론은 0.2.x composeDraft 와 동일 (자동 채택·교체 기각·실패 원복·
284
+ * abort 무간섭) + UI 분리에서도 성립.
285
+ *
286
+ * idleTimeoutMs/overallTimeoutMs: 스트림 시한 관통 — 큰 컨텍스트 창
287
+ * 운영(프리필 분 단위)은 서버 사다리와 함께 이 값도 올린다. 미지정이면
288
+ * 전송 계층 기본(90s/190s).
289
+ *
290
+ * @returns {{promise: Promise<object>, abort: ()=>void}}
291
+ */
292
+ compose({ inquiry, context, customerName, confirmOverwrite,
293
+ idleTimeoutMs, overallTimeoutMs } = {}) {
294
+ const d = this._d;
295
+ const noop = { promise: Promise.resolve(NOT_CONFIGURED()), abort: () => {} };
296
+ if (!d.client || !inquiry) {
297
+ this._setStatus(d.textOf("not_configured"));
298
+ return noop;
299
+ }
300
+ // "기존 초안" 의 정본: UI 가 붙어 있으면 에디터의 지금 값 (상담사가
301
+ // 마지막으로 본 것), 아니면 세션 버퍼.
302
+ const existing = this._ui && typeof this._ui.getDraft === "function"
303
+ ? String(this._ui.getDraft() || "")
304
+ : this._draft;
305
+ if (existing.trim() && !(confirmOverwrite && confirmOverwrite())) {
306
+ // 상담사가 쓰던 초안이 우선한다 — 조용히 덮지 않는다.
307
+ return { promise: Promise.resolve({ ...NOT_CONFIGURED(), declinedReason: "" }),
308
+ abort: () => {} };
309
+ }
310
+ // 새 스트림 = 이전 채택의 교체 시도 — 확정 전까지 보류 (0.2.3 계약)
311
+ const supersededId = this._answerId;
312
+ const supersededDraft = existing;
313
+ this._answerId = null;
314
+ const seq = ++this._composeSeq;
315
+ const stale = () => seq !== this._composeSeq;
316
+
317
+ const name = String(customerName || "").trim();
318
+ const contextWithName = name
319
+ ? { ...(context || {}), customerName: name }
320
+ : context;
321
+ const decorate = (text) => d.decorate(text, { customerName: name });
322
+
323
+ const controller = new AbortController();
324
+ let aborted = false;
325
+ let accumulated = "";
326
+ let pending = null;
327
+ const flushDraft = () => {
328
+ pending = null;
329
+ this._draft = decorate(accumulated);
330
+ this._setUiDraft(this._draft);
331
+ };
332
+ const queueDraft = () => {
333
+ // trailing 스로틀 — 청크마다 리렌더하면 큰 화면이 버벅인다
334
+ if (pending === null) pending = setTimeout(flushDraft, 80);
335
+ };
336
+ const clearPending = () => {
337
+ if (pending !== null) { clearTimeout(pending); pending = null; }
338
+ };
339
+
340
+ this._state = "composing";
341
+ this._setStatus(d.messages.ui_requesting);
342
+ this._draft = "";
343
+ this._setUiDraft("");
344
+
345
+ const promise = d.client.getAnswerStream(inquiry, contextWithName, {
346
+ signal: controller.signal,
347
+ ...(idleTimeoutMs ? { idleTimeoutMs } : {}),
348
+ ...(overallTimeoutMs ? { overallTimeoutMs } : {}),
349
+ onDelta: (text) => { accumulated += text; queueDraft(); },
350
+ onRestart: () => {
351
+ // 계약 위반 교정 — 지금까지 보인 초안은 폐기본이다
352
+ clearPending();
353
+ accumulated = "";
354
+ this._draft = "";
355
+ this._setUiDraft("");
356
+ this._setStatus(d.messages.ui_correcting);
357
+ },
358
+ onStage: (phase) => {
359
+ if (phase === "generating") this._setStatus(d.messages.ui_requesting);
360
+ },
361
+ }).then((result) => {
362
+ clearPending();
363
+ if (aborted || stale()) {
364
+ // 소비자 주도 중단(또는 뒤이은 compose 가 추월) — 화면·정본을 만지지
365
+ // 않는다. 0.2.3: 이전 문의 초안이 새 에디터에 주입되던 오염 경로 차단.
366
+ return result;
367
+ }
368
+ // 처리 선택지 — 답변·거절 무관하게 payload 에 실린다 (만료는 expires_at)
369
+ this._offers = Array.isArray(result.raw && result.raw.suggested_actions)
370
+ ? result.raw.suggested_actions.filter((o) => o && o.offer_id) : [];
371
+ this._notifyActions();
372
+ if (result.answered) {
373
+ this._draft = decorate(result.answer); // 정본 확정 (strip+데코레이터)
374
+ this._setUiDraft(this._draft);
375
+ const newId = result.answerId ?? null;
376
+ if (supersededId && supersededId !== newId) {
377
+ // 교체 확정 — 이전 제안은 기각으로 원장에 남긴다 (침묵 소거 금지)
378
+ d.client.discarded(supersededId, "", "redraft");
379
+ }
380
+ this._answerId = newId;
381
+ this._state = "drafted";
382
+ this._setStatus("");
383
+ this._persist();
384
+ } else {
385
+ // 실패·거절 — 미완성 텍스트를 정본·화면에 남기지 않는다.
386
+ // 채택 기록도 함께 원복 (0.2.3 비대칭 수정 승계).
387
+ this._draft = supersededDraft;
388
+ this._answerId = supersededId;
389
+ this._state = supersededDraft || supersededId ? "drafted" : "declined";
390
+ this._setUiDraft(supersededDraft);
391
+ this._setStatus(d.textOf(result.declinedReason || "unknown"));
392
+ this._persist();
393
+ }
394
+ return result;
395
+ });
396
+ return {
397
+ promise,
398
+ abort: () => {
399
+ // 중단 = 화면·정본은 그대로, 원장 상태는 요청 전으로 복귀
400
+ aborted = true;
401
+ this._answerId = supersededId;
402
+ this._draft = supersededDraft;
403
+ this._state = supersededDraft || supersededId ? "drafted" : "idle";
404
+ clearPending();
405
+ controller.abort();
406
+ },
407
+ };
408
+ }
409
+
410
+ /** 제안을 에디터에 넣었다 (패널 채택 버튼) */
411
+ noteAdopted(answerId) {
412
+ this._answerId = answerId || null;
413
+ if (this._answerId) this._state = "drafted";
414
+ this._persist();
415
+ }
416
+
417
+ /** 채택 기록 폐기 — 원장 통지 없는 로컬 클리어 (0.2.x clearAdopted) */
418
+ clearAdopted() {
419
+ this._answerId = null;
420
+ this._persist();
421
+ }
422
+
423
+ _emit(send, consume) {
424
+ const id = this._answerId;
425
+ if (consume) {
426
+ this._answerId = null;
427
+ this._draft = "";
428
+ this._statusText = "";
429
+ this._state = "idle";
430
+ this._clearPersist(); // 발송·폐기된 초안을 브라우저에 남기지 않는다
431
+ }
432
+ if (!this._d.client || !id) return;
433
+ send(id);
434
+ }
435
+
436
+ /** 발송 — 채택본이었다면 gold 쌍 후킹, 마지막 판단이라 기록을 비운다 */
437
+ answerSent(finalText, agent) {
438
+ this._emit((id) => this._d.client.sent(id, finalText, agent), true);
439
+ }
440
+
441
+ /** 품질 점수 1~5 — 발송 전 여러 번 가능 (비소모) */
442
+ scored(score, agent) {
443
+ this._emit((id) => this._d.client.scored(id, score, agent), false);
444
+ }
445
+
446
+ /** 수정 — 초안↔최종본 델타 (비소모) */
447
+ edited(finalText, agent) {
448
+ this._emit((id) => this._d.client.edited(id, finalText, agent), false);
449
+ }
450
+
451
+ /** 폐기 + 이유 — 마지막 판단이라 기록을 비운다 */
452
+ discarded(agent, note) {
453
+ this._emit((id) => this._d.client.discarded(id, agent, note), true);
454
+ }
455
+ }
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,39 @@ 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;
45
+
46
+ // ── 처리 액션 (actions/1) 뷰 ──
47
+ export declare const ACTION_CLS: Record<
48
+ "root" | "item" | "label" | "params" | "risk" | "button" | "confirm" | "confirmText" | "ack" | "status" | "reconcile",
49
+ string
50
+ >;
51
+ export interface ActionOfferItemView {
52
+ offerId: string; label: string; params: string; riskLabel: string; risk: string;
53
+ canExecute: boolean; buttonLabel: string; confirming: boolean; confirmText: string;
54
+ needsAck: boolean; ackLabel: string; okLabel: string; cancelLabel: string;
55
+ state: string; statusText: string; terminal: boolean;
56
+ canReconcile: boolean; reconcileLabel: string; requestId: string;
57
+ }
58
+ export declare function actionOffersView(input: {
59
+ offers?: unknown[]; pending?: unknown[]; enabled?: boolean; confirming?: string | null;
60
+ messages?: Record<string, string>;
61
+ }): { visible: boolean; items: ActionOfferItemView[]; disabledHint: string };
62
+ /** action: "confirm" | "execute" | "cancel" | "reconcile" | "ack" — 텍스트 노드만 */
63
+ export declare function actionOffersTree(input: Parameters<typeof actionOffersView>[0]): AiSuggestNode | null;