@fcg-labs/cx-agent-hook 0.3.0 → 0.5.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/react.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { createElement as h, useCallback, useEffect, useRef, useState } from "react";
21
21
 
22
- import { aiSuggestTree, aiSuggestView, CLS } from "./view.js";
22
+ import { actionOffersTree, aiSuggestTree, aiSuggestView, CLS } from "./view.js";
23
23
 
24
24
  // 표시 판단은 프레임워크 중립 코어가 갖는다 — Vue·웹 컴포넌트와 같은 것을 쓴다.
25
25
  export { aiSuggestView };
@@ -97,3 +97,82 @@ export function AiSuggestPanel({ hook, inquiry, context, onAdopt, className }) {
97
97
  }
98
98
 
99
99
  export default AiSuggestPanel;
100
+
101
+ // ── 처리 선택지 카드 (actions/1 프로파일 B) ───────────────────────────────
102
+ //
103
+ // 답변 패널과 같은 이유로 라이브러리에 있다: 카드 상태(잠김·비활성·확인 2단·
104
+ // 실행 중·완료·실패·확인 필요·미보고), 확인 문구, 재실행 금지 규약은 전부 제품
105
+ // 지식이다. 고객사는 배치만 한다 — 세션과 행위자 이름을 넘기면 끝.
106
+ //
107
+ // ```jsx
108
+ // import { ActionOffersPanel } from "@fcg-labs/cx-agent-hook/react";
109
+ // <ActionOffersPanel session={getCsSession(csId)} agent={agent} actorClaimed={answerWriter} />
110
+ // ```
111
+
112
+ /**
113
+ * @param {object} props
114
+ * @param {object} props.session agent.session(externalId) — offers·pending·executeAction 의 소유자
115
+ * @param {object} props.agent createCxAgent() 결과 — actionsEnabled·messages
116
+ * @param {string} [props.actorClaimed] 표시용 행위자 이름(자기 주장) — 원장에 남는다
117
+ * @param {(r: object) => void} [props.onResult] 실행 결과 콜백 ({requestId, state, reasonCode, message, reported})
118
+ * @param {string} [props.className] 바깥 배치용 (레이아웃만)
119
+ */
120
+ export function ActionOffersPanel({ session, agent, actorClaimed, onResult, className }) {
121
+ const [offers, setOffers] = useState(() => (session ? session.offers : []));
122
+ const [pending, setPending] = useState(() => (session ? session.pendingActions() : []));
123
+ const [confirming, setConfirming] = useState(null);
124
+ const [ackedOffer, setAckedOffer] = useState(null); // 고위험 확인은 **offer 단위** — 다른 카드의 체크가 이월되지 않는다
125
+
126
+ // 세션이 offers·pending 을 밀어준다 — 답변이 오거나 실행 상태가 바뀔 때
127
+ useEffect(() => {
128
+ if (!session || typeof session.attachActionUi !== "function") return undefined;
129
+ session.attachActionUi({ setOffers: (o, p) => { setOffers(o); setPending(p); } });
130
+ return () => session.detachActionUi();
131
+ }, [session]);
132
+ useEffect(() => { setConfirming(null); setAckedOffer(null); }, [session]);
133
+
134
+ const actorRef = useRef(actorClaimed);
135
+ actorRef.current = actorClaimed;
136
+
137
+ const execute = useCallback(async (offerId) => {
138
+ const offer = offers.find((o) => o.offer_id === offerId);
139
+ if (!offer) return;
140
+ if (offer.risk_level === "high" && ackedOffer !== offerId) return; // 2단 확인 미완
141
+ setConfirming(null); setAckedOffer(null);
142
+ const r = await session.executeAction(offer, { actorClaimed: actorRef.current });
143
+ if (onResult) onResult(r);
144
+ }, [offers, ackedOffer, session, onResult]);
145
+
146
+ if (!session || !agent) return null;
147
+ const tree = actionOffersTree({ offers, pending, enabled: Boolean(agent.actionsEnabled), confirming, messages: agent.messages });
148
+ if (!tree) return null;
149
+
150
+ const actions = {
151
+ confirm: (e) => { setConfirming(e.currentTarget.dataset.offer || null); setAckedOffer(null); },
152
+ cancel: () => { setConfirming(null); setAckedOffer(null); },
153
+ ack: (e) => { const id = e.currentTarget.dataset.offer; setAckedOffer((v) => (v === id ? null : id)); },
154
+ execute: (e) => execute(e.currentTarget.dataset.offer),
155
+ resend: (e) => session.resendResult(e.currentTarget.dataset.request),
156
+ };
157
+ const render = (node, key) => {
158
+ const props = { key, className: node.cls };
159
+ if (node.tag === "button") { props.type = node.type || "button"; }
160
+ if (node.action && actions[node.action]) {
161
+ props.onClick = actions[node.action];
162
+ if (node.offerId) props["data-offer"] = node.offerId;
163
+ if (node.requestId) props["data-request"] = node.requestId;
164
+ if (node.action === "execute" && confirming) {
165
+ const o = offers.find((x) => x.offer_id === node.offerId);
166
+ if (o && o.risk_level === "high" && ackedOffer !== node.offerId) props.disabled = true;
167
+ }
168
+ if (node.pressed) props["aria-pressed"] = ackedOffer === node.offerId;
169
+ }
170
+ if (node.live) props.role = "status";
171
+ if (node.state) props["data-state"] = node.state;
172
+ if (node.risk) props["data-risk"] = node.risk;
173
+ return h(node.tag, props, node.children ? node.children.map((c, i) => render(c, i)) : node.text);
174
+ };
175
+ return h("div", { className: className ? `${tree.cls} ${className}` : tree.cls,
176
+ onClick: (event) => event.stopPropagation() },
177
+ tree.children.map((n, i) => render(n, i)));
178
+ }
package/session.js CHANGED
@@ -20,6 +20,50 @@
20
20
  * 남기지 않는다 — 프라이버시 경계).
21
21
  */
22
22
 
23
+ /** 처리 액션 pending 저장소 — 세션 영속(_persist·TTL 30분·consume 삭제)과
24
+ * **수명이 다르다**: 요청은 전송 **전에** 남고, 종단 뒤 24h 까지 살아 새로고침·
25
+ * 재진입 시 "이미 눌렀던 것" 을 안다. 정본은 허브 원장이다 — 브라우저 소실은
26
+ * 기록 손실이 아니라 표시 손실일 뿐. localStorage 부재면 메모리로만 산다. */
27
+ import { executeViaAdminApi } from "./actions.js";
28
+
29
+ export class ActionLedger {
30
+ constructor({ impl, prefix = "cx-agent-actions", terminalTtlMs = 24 * 60 * 60 * 1000 } = {}) {
31
+ this._impl = impl || (typeof localStorage !== "undefined" ? localStorage : null);
32
+ this._key = prefix;
33
+ this._ttl = terminalTtlMs;
34
+ this._mem = new Map();
35
+ }
36
+ _all() {
37
+ if (!this._impl) return this._mem;
38
+ try {
39
+ const raw = JSON.parse(this._impl.getItem(this._key) || "{}");
40
+ return new Map(Object.entries(raw));
41
+ } catch { return new Map(); }
42
+ }
43
+ _save(map) {
44
+ if (!this._impl) { this._mem = map; return; }
45
+ try { this._impl.setItem(this._key, JSON.stringify(Object.fromEntries(map))); } catch { /* quota */ }
46
+ }
47
+ put(requestId, rec) {
48
+ const m = this._all();
49
+ m.set(requestId, { ...rec, at: Date.now() });
50
+ this._save(this._prune(m));
51
+ }
52
+ get(requestId) { return this._all().get(requestId) || null; }
53
+ forInquiry(externalId) {
54
+ const out = [];
55
+ for (const [rid, r] of this._all()) if (r.externalId === externalId) out.push({ requestId: rid, ...r });
56
+ return out;
57
+ }
58
+ _prune(m) {
59
+ const now = Date.now();
60
+ for (const [rid, r] of m) {
61
+ if ((r.state === "succeeded" || r.state === "failed") && now - (r.at || 0) > this._ttl) m.delete(rid);
62
+ }
63
+ return m;
64
+ }
65
+ }
66
+
23
67
  const NOT_CONFIGURED = () => ({
24
68
  ok: false, answered: false, answer: "", answerId: null,
25
69
  evidence: [], declinedReason: "not_configured", raw: {},
@@ -109,6 +153,9 @@ export class InquirySession {
109
153
  this._statusText = "";
110
154
  this._answerId = null;
111
155
  this._composeSeq = 0;
156
+ this._offers = []; // 마지막 답변의 처리 선택지 (actions/1 §4)
157
+ this._actionUi = null;
158
+ this._locks = {}; // action_key\x00target_key → 잠금 조회 결과 (세션 캐시)
112
159
 
113
160
  // 영속 복원 — TTL 만료분은 store 가 이미 걸렀다
114
161
  if (this._id && deps.store) {
@@ -123,6 +170,142 @@ export class InquirySession {
123
170
  }
124
171
 
125
172
  get externalId() { return this._id; }
173
+ /**
174
+ * 처리 선택지 (만료 제외) + 브라우저 판정 두 가지를 병합해 돌려준다:
175
+ * available: 이 CMS 가 그 API 를 아는가 (adminApi.endpoints[endpoint_key]) — fail-closed
176
+ * locked: 같은 대상·같은 입력의 최근 성공이 허브에 있는가 (refreshActionLocks 결과)
177
+ * 실행 여부는 pendingActions() 로.
178
+ */
179
+ get offers() {
180
+ const now = Date.now();
181
+ const d = this._d;
182
+ const endpoints = d.adminApi && d.adminApi.endpoints;
183
+ return this._offers
184
+ .filter((o) => !o.expires_at || Date.parse(o.expires_at) > now)
185
+ .map((o) => {
186
+ const ek = o.execution && o.execution.endpoint_key;
187
+ const available = Boolean(d.adminApi && typeof d.adminApi.request === "function" && ek && endpoints && endpoints[ek]);
188
+ const lock = this._locks[`${o.action_key}\x00${o.target_key || ""}`];
189
+ const locked = Boolean(lock && lock.locked && lock.paramsHash && lock.paramsHash === o.params_hash);
190
+ return { ...o, available, locked, lock: locked ? { at: lock.at, actorClaimed: lock.actorClaimed, requestId: lock.requestId } : null };
191
+ });
192
+ }
193
+ attachActionUi(ui) { this._actionUi = ui; this._notifyActions(); }
194
+ detachActionUi() { this._actionUi = null; }
195
+ _notifyActions() {
196
+ if (this._actionUi && typeof this._actionUi.setOffers === "function") {
197
+ try { this._actionUi.setOffers(this.offers, this.pendingActions()); } catch { /* UI 오류가 세션을 죽이지 않는다 */ }
198
+ }
199
+ }
200
+ /** 이 문의에서 눌렀던 처리들 (pending·unknown·종단 24h 내) — 정본은 허브 원장 */
201
+ pendingActions() {
202
+ const d = this._d;
203
+ return d.actionLedger ? d.actionLedger.forInquiry(this._id) : [];
204
+ }
205
+ /**
206
+ * 잠금 갱신 — target_key 가 있는 offer 마다 허브에 (action_key, target_key) 최근 성공을
207
+ * 1회 묻는다. 세션 수명 동안 캐시(같은 키는 다시 안 묻는다; 실행 성공 후엔 자기가
208
+ * 갱신). 실패는 조용히 — 잠금은 편의이고 정본은 허브 원장·CMS 정책이다.
209
+ */
210
+ async refreshActionLocks() {
211
+ const d = this._d;
212
+ if (!d.client) return;
213
+ const jobs = [];
214
+ for (const o of this._offers) {
215
+ if (!o.target_key || !o.action_key) continue;
216
+ const k = `${o.action_key}\x00${o.target_key}`;
217
+ if (this._locks[k] !== undefined) continue;
218
+ this._locks[k] = null; // in-flight 표시 — 병렬 중복 질의 방지
219
+ jobs.push(d.client.actionLock(o.action_key, o.target_key).then((r) => {
220
+ this._locks[k] = r && r.ok ? r : { locked: false };
221
+ }).catch(() => { this._locks[k] = { locked: false }; }));
222
+ }
223
+ if (jobs.length) { await Promise.all(jobs); this._notifyActions(); }
224
+ }
225
+ /**
226
+ * 처리 실행 (프로파일 B, 3단) — 사람 확인은 호출자(UI)가 끝낸 뒤 부른다.
227
+ * ① 허브 선점: request_id 를 발급해 **전송 전에** 영속, 재시도 0. 네트워크 오류면
228
+ * 상태 조회 1회 → 404(미도달)일 때만 같은 request_id 로 1회 재전송 (선점은 부작용
229
+ * 0 이라 안전). 선점이 안 되면 CMS 를 부르지 않는다 (원장 없는 실행 금지).
230
+ * ② CMS 자기 API 호출: adminApi.request 1회, 카탈로그 response_rule 로 판정.
231
+ * ③ 결과 보고: 실패하면 ledger 에 unreported 로 남기고 resendResult 로 재보고.
232
+ * unknown(전송 후 결과 불명)은 재실행하지 않는다 — 카드가 "확인 필요"로 남는다.
233
+ */
234
+ async executeAction(offer, { actorClaimed } = {}) {
235
+ const d = this._d;
236
+ if (!d.client || !offer || !offer.offer_id) return { ok: false, state: "", error: "not_configured" };
237
+ if (!d.adminApi || typeof d.adminApi.request !== "function") return { ok: false, state: "", error: "admin_api_missing" };
238
+ const endpoints = d.adminApi.endpoints || {};
239
+ const ek = offer.execution && offer.execution.endpoint_key;
240
+ if (!ek || !endpoints[ek]) return { ok: false, state: "", error: "endpoint_missing" };
241
+ const requestId = d.newRequestId();
242
+ const rec = { externalId: this._id, offerId: offer.offer_id, actionKey: offer.action_key,
243
+ targetKey: offer.target_key || "", paramsHash: offer.params_hash || "",
244
+ label: offer.label || "", state: "pending" };
245
+ if (d.actionLedger) d.actionLedger.put(requestId, rec);
246
+ // ① 선점
247
+ const args = { requestId, offer, actorClaimed, inquiryRef: this._id };
248
+ let r = await d.client.requestAction(args);
249
+ if (!r.ok && r.error === "network_error") {
250
+ const st = await d.client.actionStatus(requestId);
251
+ if (st.ok && st.state === "none") r = await d.client.requestAction(args); // 미도달 확정 — 1회 재전송
252
+ else if (st.ok) r = { ...r, ok: true, state: st.state, error: null };
253
+ }
254
+ if (!r.ok) {
255
+ if (d.actionLedger) d.actionLedger.put(requestId, { ...rec, state: "failed", reasonCode: r.error || "reserve_failed", message: r.message || "" });
256
+ this._notifyActions();
257
+ return { requestId, ...r, state: "failed" };
258
+ }
259
+ if (r.state && r.state !== "received") {
260
+ // 재제출로 이미 종단·진행 중인 기록 — 그대로 (재실행 없음)
261
+ if (d.actionLedger) d.actionLedger.put(requestId, { ...rec, state: r.state, reasonCode: r.reasonCode || "", message: r.message || "" });
262
+ this._notifyActions();
263
+ return { requestId, ...r };
264
+ }
265
+ if (d.actionLedger) d.actionLedger.put(requestId, { ...rec, state: "received" });
266
+ // ② CMS 자기 API — 재시도 0
267
+ const out = await executeViaAdminApi(d.adminApi, offer);
268
+ if (out.authExpired && typeof d.adminApi.onAuthExpired === "function") {
269
+ try { d.adminApi.onAuthExpired(out.body); } catch { /* 호출자 오류 무시 */ }
270
+ }
271
+ // ③ 보고
272
+ const report = { state: out.state, reasonCode: out.reasonCode, message: out.message,
273
+ httpStatus: out.httpStatus, latencyMs: out.latencyMs };
274
+ const rep = await d.client.reportActionResult(requestId, report);
275
+ const unreported = !rep.ok && rep.httpStatus === 0; // 네트워크 — 다시 보고 가능
276
+ if (d.actionLedger) {
277
+ d.actionLedger.put(requestId, { ...rec, state: out.state, reasonCode: out.reasonCode || "",
278
+ message: out.message || "", unreported, pendingReport: unreported ? report : undefined });
279
+ }
280
+ if (out.state === "succeeded" && offer.target_key) {
281
+ this._locks[`${offer.action_key}\x00${offer.target_key}`] = { locked: true, paramsHash: offer.params_hash,
282
+ at: new Date().toISOString(), actorClaimed: actorClaimed || "", requestId };
283
+ }
284
+ this._notifyActions();
285
+ return { requestId, ok: true, state: out.state, reasonCode: out.reasonCode, message: out.message,
286
+ reported: !unreported, error: null };
287
+ }
288
+ /** 미보고 결과 재보고 — 재실행이 아니다. ledger 의 pendingReport 만 다시 보낸다. */
289
+ async resendResult(requestId) {
290
+ const d = this._d;
291
+ if (!d.client || !d.actionLedger || !requestId) return { ok: false, error: "not_configured" };
292
+ const rec = d.actionLedger.get(requestId);
293
+ if (!rec || !rec.pendingReport) return { ok: false, error: "nothing_to_report" };
294
+ const rep = await d.client.reportActionResult(requestId, rec.pendingReport);
295
+ if (rep.ok || (rep.httpStatus && rep.httpStatus !== 0)) {
296
+ // 허브가 받았거나(200) 거절 확정(409/404/422)이면 더 재보고할 것이 없다
297
+ d.actionLedger.put(requestId, { ...rec, unreported: false, pendingReport: undefined,
298
+ reportError: rep.ok ? "" : (rep.error || "") });
299
+ }
300
+ this._notifyActions();
301
+ return rep;
302
+ }
303
+ /** 미보고 전부 재시도 — 세션 진입·offer 갱신 시 1회 (fire-and-forget). */
304
+ async flushUnreportedActions() {
305
+ for (const p of this.pendingActions()) {
306
+ if (p.unreported && p.pendingReport) await this.resendResult(p.requestId);
307
+ }
308
+ }
126
309
  get state() { return this._state; }
127
310
  get draft() { return this._draft; }
128
311
  get adoptedAnswerId() { return this._answerId; }
@@ -263,6 +446,15 @@ export class InquirySession {
263
446
  // 않는다. 0.2.3: 이전 문의 초안이 새 에디터에 주입되던 오염 경로 차단.
264
447
  return result;
265
448
  }
449
+ // 처리 선택지 — 답변·거절 무관하게 payload 에 실린다 (만료는 expires_at)
450
+ this._offers = Array.isArray(result.raw && result.raw.suggested_actions)
451
+ ? result.raw.suggested_actions.filter((o) => o && o.offer_id) : [];
452
+ this._notifyActions();
453
+ // 잠금 조회·미보고 재보고는 백그라운드 — 답변 표시를 막지 않는다
454
+ if (this._offers.length) {
455
+ this.refreshActionLocks().catch(() => {});
456
+ this.flushUnreportedActions().catch(() => {});
457
+ }
266
458
  if (result.answered) {
267
459
  this._draft = decorate(result.answer); // 정본 확정 (strip+데코레이터)
268
460
  this._setUiDraft(this._draft);
@@ -0,0 +1,195 @@
1
+ ---
2
+ name: cx-agent-hook-setup
3
+ description: Use when a developer asks to set up, wire, integrate, add, or upgrade "@fcg-labs/cx-agent-hook" (a.k.a. cx-agent-hook, CX agent hook, CS AX hook, FCG CX Agent) in a CMS / admin web app — the AI answer-draft panel AND the action cards (adminApi). Triggers include "cx-agent-hook 셋업", "cx-agent-hook 붙여줘", "CS AX 연동", "AI 답변 초안 패널 붙이기", "처리 카드 / 처리 액션 붙여", "adminApi 배선", "hook 0.5 로 올려", "wire cx-agent-hook", "install cx agent hook", "action cards". Also use for upgrades from 0.2/0.3/0.4 to 0.5. Never skip the adminApi step unless the project has no admin API at all.
4
+ ---
5
+
6
+ # cx-agent-hook 셋업 — 에이전트 작업 절차
7
+
8
+ `@fcg-labs/cx-agent-hook` 은 고객사 관리자 화면(CMS)에 두 가지를 붙이는 SDK 다:
9
+ ① **AI 답변 초안** 패널(문의 → 초안 → 상담사 편집·발송 후킹) ② **처리 카드**(답변 옆에
10
+ "이 처리를 할까요?" 카드 — 누르면 **이 CMS 의 기존 관리자 API 를 CMS 세션으로** 호출).
11
+ 설치·환경변수·훅 파일·슬롯 2개·확인, 여섯 단계가 전부다. 서버에 새로 만드는 것은 없다.
12
+
13
+ **이 스킬을 따르는 에이전트의 완료 정의**: 아래 §5 체크리스트 전부 참. 특히
14
+ `adminApi` 를 넘긴 훅 + `agent` export + 처리 카드 슬롯이 빠지면 미완이다 — 개발자가
15
+ "답변 패널만" 이라고 명시하지 않는 한 처리 카드까지가 셋업이다.
16
+
17
+ 정확한 API 이름·옵션·이벤트는 같은 폴더의 **reference.md** 가 정본이다. 거기 없는
18
+ export·옵션·컴포넌트를 쓰지 말 것 (`node_modules/@fcg-labs/cx-agent-hook/*.d.ts` 로
19
+ 재확인 가능). 이 문서의 코드는 그 정본에서 만든 예다.
20
+
21
+ ## 0. 하는 일 / 하지 않는 일
22
+
23
+ | 한다 | 하지 않는다 |
24
+ |---|---|
25
+ | 패키지 설치·범위 상향, `.env` 3값, 훅 파일 1개, 답변 슬롯, 처리 카드 슬롯, 확인 | 서버 엔드포인트·어댑터·서명 검증·토큰 발급 코드 (없어야 정상) |
26
+ | 이 CMS 에 **이미 있는** 요청 헬퍼·엔드포인트 맵·세션 만료 처리의 **참조**를 넘김 | 새 관리자 API 를 만들거나 기존 API 의 정책(권한·횟수 제한)을 바꾸는 일 |
27
+ | 프로젝트 성격(프레임워크·번들러·경로 별칭·헬퍼 시그니처)에 맞춰 코드를 조정 | 비밀 생성·저장 (토큰은 운영자가 CS AX 공장 시스템 탭 [연동] 에서 받아 준다) |
28
+ | 실측 표를 먼저 쓰고, 편집 후 빌드·확인까지 | 재시도·자동 실행·정책 판단을 SDK 밖에서 덧붙이는 일 |
29
+
30
+ ## 1. 먼저 실측한다 (편집 전에 아래 표를 채워 개발자에게 보여준다)
31
+
32
+ | 항목 | 어떻게 찾나 | 결정에 쓰이는 곳 |
33
+ |---|---|---|
34
+ | 프레임워크 | `package.json` dependencies: `react`·`vue`·둘 다 없음(순수 HTML/Astro/Angular) | 어댑터 서브패스: `/react` · `/vue` · `/element` |
35
+ | 번들러·env 접근 | `vite.config.*` → `import.meta.env.VITE_*` / webpack·CRA → `process.env.REACT_APP_*` / Next → `process.env.NEXT_PUBLIC_*` / 번들러 없음 → 값을 훅 파일에 직접 | `.env` 키 접두·훅 파일의 env 읽기 |
36
+ | 경로 별칭 | `vite.config.*`/`tsconfig.json` `paths`/`jsconfig.json` (`@`, `@src`, `~` …) | 화면 파일에서 훅을 import 하는 경로 |
37
+ | 기존 훅 | `grep -rn "cx-agent-hook\|createCxAgent\|createCxHook" src` | 없음 → 새로 만든다 / `createCxHook`(0.2) → 0.3 세션 표면으로 / `createCxAgent`(0.3~0.4) → `adminApi` 추가·`agent` export |
38
+ | 현재 버전 | `package.json` 의 `@fcg-labs/cx-agent-hook` 범위 + `node_modules/@fcg-labs/cx-agent-hook/package.json` | `^0.3.0`/`^0.4.0` 은 0.x semver 라 0.5 를 못 받는다 → `^0.5.0` 으로 올리고 재설치 |
39
+ | **관리자 API 요청 헬퍼** | `grep -rn "axios.create\|static async request\|function request(\|apiClient\|httpClient" src` — 관리자 화면이 서버를 부를 때 쓰는 공용 함수. 시그니처(인자 이름 `method/url/path/query/data|body`)를 적는다 | `adminApi.request` — SDK 는 `{method, url, path, query, data}` 로 부르고 응답이 axios 형(`{status,data}`)이든 본문이든 받는다. 시그니처가 다르면 훅 파일에 5줄 이하 어댑터 |
40
+ | **엔드포인트 맵** | 상수 파일: `{KEY: "…/users/:id/…"}` 모양의 URL 맵(예: `EndPoint`, `API_MAP`, `endpoints`). `:param` 표기가 있으면 그것 | `adminApi.endpoints` — 처리 카드 후보 목록·실행 URL 의 원천 |
41
+ | 메서드 화이트리스트(선택) | `{get:{[url]:…}, post:{…}}` 처럼 메서드→URL 맵이 있으면 | `adminApi.map` — 후보의 메서드 추출용. 없으면 생략 |
42
+ | 세션 만료 처리(선택) | 4011/401 등에서 로그인으로 보내는 함수(`handleAuthExpired…`, `forceLogout…`) | `adminApi.onAuthExpired` |
43
+ | 문의 화면 | 문의 목록·답변 편집기·발송 버튼이 있는 컴포넌트. 문의 ID(`csId`·`inquiryId`), 본문, 고객 식별자(`userId`·`subUserId`), 상담사 이름 변수 | 답변 슬롯·처리 카드 슬롯 위치, `context`·`actorClaimed` 값 |
44
+ | 연동 값 | 개발자가 받은 허브 주소·브라우저 토큰·도메인 (CS AX 공장 [연동] 화면의 `.env` 3줄) | 없으면 자리표시자로 두고 **어디서 받는지** 명시(값을 지어내지 않는다) |
45
+
46
+ 관리자 API 헬퍼·엔드포인트 맵이 **정말 없으면**(관리자 화면이 서버를 직접 부르지 않는 구조)
47
+ 개발자에게 그 사실을 알리고 `adminApi` 없이 진행한다 — 이때 처리 카드는 나오지 않고
48
+ 답변 패널만 동작한다는 것을 결과 보고에 적는다. 있는데 빼먹는 것은 실패다.
49
+
50
+ ## 2. 결정 규칙
51
+
52
+ - 어댑터: React → `/react` `AiSuggestPanel`·`ActionOffersPanel` / Next App Router → `/next`(같은 이름,
53
+ `"use client"` 경계를 라이브러리가 선언; `/react` 를 직접 쓰면 그 파일 첫 줄에 `"use client"`) / Vue 3 → `/vue`
54
+ 같은 이름 / Astro·Angular·순수 HTML → `/element`(또는 `/astro`·`/angular` 재수출) `<cx-ai-suggest>`·
55
+ `<cx-action-offers>`. 스타일은 공통 `styles.css` 1회 import(진입 파일이나 슬롯 컴포넌트 중 한 곳으로 확정).
56
+ - 훅은 **한 파일에서 한 번** 만들고 `agent`·`getCsSession` 을 export 한다. 화면은 그 결과만
57
+ import 한다. 이 파일에는 배선 외 로직(상수·화면 판단)을 넣지 않는다.
58
+ - `adminApi.request` 는 기존 헬퍼를 **참조로** 넘긴다. 시그니처가 SDK 와 다르면 훅 파일 안에
59
+ 얇은 어댑터(인자 이름 매핑만)를 둔다 — 예: `request: (a) => AdminApi.call({ ...a, body: a.data })`.
60
+ `this` 를 쓰는 인스턴스 메서드면 `bind` 한다.
61
+ - `endpoints` = 키→URL 템플릿(엔드포인트 맵), `map` = 메서드 화이트리스트. **바꿔 넣으면**
62
+ 후보 발행이 조용히 0건이 된다(에러 없음).
63
+ - `actorClaimed` = 답변 발송에 쓰는 상담사 이름과 **같은 변수** (원장 대조).
64
+ - 처리 카드 슬롯은 세션당 1곳(인스펙터/상세 패널). 문의 목록 행마다 붙이지 않는다 — 세션의
65
+ 카드 sink 는 하나라(`attachActionUi` 덮어쓰기) 두 곳이면 서로 끊는다. 카드는 **그 문의에서 초안
66
+ (`compose`)을 한 번 받은 뒤**에만 나오고 새로고침하면 다시 초안을 받아야 한다 — 슬롯 위치는 답변
67
+ 초안 흐름과 같은 화면이어야 한다.
68
+ - 값이 없을 때 폴백은 **빈 문자열**(`""`) — `"<허브 주소>"` 같은 자리표시자 문자열은 truthy 라 `enabled`
69
+ 가 참으로 나와 확인 단계를 거짓 통과한다.
70
+ - 기존 훅이 `storage: { enabled: false }` 면 처리 원장(미보고 재보고·눌렀던 것 기억)도 함께 꺼진다 —
71
+ 개발자에게 알리고 `storage.actionsImpl` 로 원장만 살릴지 정한다.
72
+ - 산출 코드는 **파일 단위 완전체**로 준다(붙여넣기가 성립: Vue 는 `<script setup>` 안에, React 는 컴포넌트 안에).
73
+ 실측 항목 중 파일을 열어 확정할 수 있는 것은 묻지 않는다 — 개발자 질문은 정말 모르는 것만 5개 이하.
74
+ - 답변 패널은 0.3 세션 표면(`session.attachUi/compose/answerSent`) 이 권장. 이미 0.2
75
+ `AiSuggestPanel hook={createCxHook(...)}` 를 쓰고 있으면 그대로 두어도 되지만, 처리 카드는
76
+ `createCxAgent` 의 세션이 필요하므로 훅을 `createCxAgent` 로 만들고 `agent.session()` 을 쓴다.
77
+
78
+ ## 3. 단계 (코드는 프로젝트 실측값으로 치환한다)
79
+
80
+ ### 3-1 설치 / 범위 상향
81
+ ```bash
82
+ npm install @fcg-labs/cx-agent-hook@^0.5.0 # yarn add / pnpm add 동일
83
+ ```
84
+ `package.json` 에 `"@fcg-labs/cx-agent-hook": "^0.5.0"` 이 있는지, `node_modules/…/package.json`
85
+ 버전이 0.5.x 인지 확인한다.
86
+
87
+ ### 3-2 환경변수 (`.env` — Vite 예. 접두는 §1 번들러 규칙대로)
88
+ ```bash
89
+ VITE_CX_HUB_URL=<허브 주소>
90
+ VITE_CX_HUB_BROWSER_TOKEN=<브라우저 토큰>
91
+ VITE_CX_DOMAIN=<도메인>
92
+ ```
93
+ 값은 개발자가 CS AX 공장 [연동] 화면에서 받아 온다. 이 토큰은 도메인에 묶인 브라우저
94
+ 등급이라 번들에 실려도 되고, 학습 산출물에는 닿지 못한다.
95
+
96
+ ### 3-3 훅 파일 (`src/utils/cxAgent/hook.js` 등 프로젝트 관례 위치) — **adminApi 포함**
97
+ ```js
98
+ import { createCxAgent } from "@fcg-labs/cx-agent-hook/agent";
99
+ import AdminApi, { EndPoint } from "../../constant/AdminApi"; // ← 실측한 요청 헬퍼 + 엔드포인트 맵
100
+ import AdminApiMap from "../../constant/AdminApiMap"; // ← 실측한 메서드 화이트리스트(있을 때)
101
+ import { handleAuthExpiredResponse } from "../session/sessionManager"; // ← 실측한 세션 만료 처리(있을 때)
102
+
103
+ const env = import.meta.env; // 번들러 규칙대로
104
+ export const agent = createCxAgent({
105
+ baseUrl: env.VITE_CX_HUB_URL,
106
+ token: env.VITE_CX_HUB_BROWSER_TOKEN,
107
+ domain: env.VITE_CX_DOMAIN,
108
+ api: "hub",
109
+ locale: "ko", // ko | en | ja | zh-TW
110
+ // 처리 카드 — 이 CMS 의 관리자 API 를 CMS 세션으로 부른다 (참조만, 새로 짜는 코드 없음)
111
+ adminApi: { request: AdminApi.request, endpoints: EndPoint,
112
+ map: AdminApiMap, onAuthExpired: handleAuthExpiredResponse },
113
+ });
114
+ export const getCsSession = (csId) => agent.session(csId); // 문의별 세션 — 같은 ID 는 같은 인스턴스
115
+ export const isCxHookEnabled = agent.enabled;
116
+ ```
117
+ 훅이 뜨면 SDK 가 엔드포인트 맵의 키·메서드·경로 템플릿(호스트 제거)을 허브에 1회 발행한다 —
118
+ 운영자가 CS AX 공장에서 "이 CMS 가 이미 하는 처리" 목록으로 보고 처리 선택지를 만든다.
119
+ 파라미터 스키마는 발행되지 않는다(맵에 없다) — 운영자 몫.
120
+
121
+ ### 3-4 답변 초안 슬롯 (문의 화면)
122
+ ```js
123
+ import { getCsSession } from "<별칭>/utils/cxAgent/hook";
124
+
125
+ const session = getCsSession(inquiry.id);
126
+ session.attachUi({ getDraft: () => editorText, setDraft: setEditorText, setStatus: setAiStatus }).restore();
127
+ // [AI 초안] 버튼 — context 는 고객 정보 키(처리 카드 입력값의 원천이기도 하다). 있는 것만.
128
+ await session.compose({ inquiry: inquiry.content,
129
+ context: { external_id: inquiry.id, user_id: inquiry.userId, sub_user_id: inquiry.subUserId } }).promise;
130
+ // 발송 버튼
131
+ session.answerSent(finalText, agentName);
132
+ ```
133
+ 문의를 바꾸면 그 문의의 세션을 다시 `attachUi(...).restore()` 한다(이전 세션은 `detachUi()`).
134
+ `context` 값은 문자열로 정규화되고 빈 값은 버려진다 — 처리 카드 입력값의 원천이니 값이 있는 키만 넣는다.
135
+ 기존 프로젝트가 이미 0.3 세션 표면으로 붙어 있으면(예: `attachUi` 후 `session.draft` 를 직접 읽음) 동등한
136
+ 표면이므로 손대지 않고 "이미 배선됨"으로 정산한다.
137
+
138
+ ### 3-5 처리 카드 슬롯 (문의 상세/인스펙터 패널 1곳)
139
+ React:
140
+ ```jsx
141
+ import { ActionOffersPanel } from "@fcg-labs/cx-agent-hook/react";
142
+ import "@fcg-labs/cx-agent-hook/styles.css";
143
+ import { agent, getCsSession } from "<별칭>/utils/cxAgent/hook";
144
+
145
+ <ActionOffersPanel session={getCsSession(inquiry.id)} agent={agent} actorClaimed={agentName}
146
+ onResult={(r) => { if (r.state === "succeeded") refreshInquiry(); }} />
147
+ ```
148
+ Vue 3: `import { ActionOffersPanel } from "@fcg-labs/cx-agent-hook/vue"` →
149
+ `<ActionOffersPanel :session="getCsSession(inquiry.id)" :agent="agent" :actor-claimed="agentName" @result="onActionResult" />`
150
+ 순수 HTML/그 밖: `import { defineCxActionOffers } from "@fcg-labs/cx-agent-hook/element"; defineCxActionOffers();`
151
+ → `<cx-action-offers>` 에 `el.agent = agent; el.session = getCsSession(id); el.actorClaimed = agentName;`
152
+ 이벤트 `cx-action-result`. (번들러가 없으면 import map 으로 `@fcg-labs/cx-agent-hook/` 를
153
+ `/node_modules/@fcg-labs/cx-agent-hook/` 에 매핑하고 훅의 env 자리에 값을 직접 적는다.)
154
+
155
+ ### 3-6 확인
156
+ 1. 빌드·타입체크 통과. 2. CMS 를 열고 개발 콘솔에서(훅 파일에 개발 환경 한정으로 `window.__cxAgent = agent` —
157
+ Vite `import.meta.env.DEV`, 번들러 없으면 `location.hostname === "localhost"` 조건 — 을 두거나
158
+ `console.log(agent.actionsEnabled)`) `actionsEnabled === true`, `await agent.candidatesReady` 가
159
+ `{ok:true, count:N}`. 3. 운영자에게 "공장 시스템 탭 [처리 액션] 에 후보 N건 도착" 을 확인해 달라고
160
+ 전한다. 4. 운영자가 처리 선택지를 승인하면, **어떤 문의에서 [AI 초안]을 받은 뒤** 그 답변 옆에 카드가
161
+ 나온다(`session.offers.length > 0`). 초안을 받기 전·새로고침 직후에는 카드가 없는 것이 정상이다.
162
+
163
+ ## 4. 규약 (어기면 되돌린다)
164
+ - `adminApi` 없이 끝내지 않는다(관리자 API 자체가 없을 때만 예외 — 보고에 명시).
165
+ - 훅 파일은 `agent`·`getCsSession` 을 export 한다 — 카드 컴포넌트가 `agent` 를 직접 받는다.
166
+ - 서버 코드·새 엔드포인트·서명·토큰 발급을 만들지 않는다. 비밀을 저장소에 넣지 않는다.
167
+ - SDK 호출에 재시도·자동 실행을 덧대지 않는다(계약: 재시도 0, 사람 확인 후 실행).
168
+ - reference.md 에 없는 이름을 쓰지 않는다. `context` 는 값이 있는 키만 넣는다.
169
+ - 기존 동작(문의 목록·발송 로직)은 건드리지 않는다 — 배선만 더한다.
170
+
171
+ ## 5. 완료 체크리스트 (전부 참이어야 완료)
172
+ - [ ] `^0.5.0` 이상 설치·재설치됨
173
+ - [ ] `.env` 3값(또는 자리표시자 + 어디서 받는지 명시)
174
+ - [ ] 훅 파일 1개: `createCxAgent({... adminApi: {request, endpoints, map?, onAuthExpired?}})`, `export const agent`, `getCsSession`
175
+ - [ ] 답변 슬롯: `attachUi(...).restore()` · `compose({inquiry, context}).promise` · `answerSent(finalText, agentName)`
176
+ - [ ] 처리 카드 슬롯 1곳 (`ActionOffersPanel` / `<cx-action-offers>`) + `styles.css` import + `actorClaimed`
177
+ - [ ] 빌드 통과 · `agent.actionsEnabled === true` · `candidatesReady.ok` · (선택지 승인 후) 초안 요청한 문의에서 `session.offers.length > 0` 로 카드 노출
178
+ - [ ] 결과 보고: 실측 표 · 바뀐 파일 목록 · 운영자에게 전할 확인 항목 · 넘긴 참조 3~4개의 이름
179
+
180
+ ## 6. 업그레이드 요약 (자세한 것은 패키지 MIGRATION.md)
181
+ - 0.2 `createCxHook`/`AiSuggestPanel hook=` → 0.3 `createCxAgent`+`agent.session()` (호환 표면은 남아 있음)
182
+ - 0.4 의 `actorAssertion` 옵션·`session.reconcileAction` 은 0.5 에서 **삭제됐다** → 대체는 `adminApi` 옵션·
183
+ `session.resendResult` (둘 다 0.5 신설이며 필수/권장).
184
+ - 처리 카드는 0.5 부터. `^0.3.0`/`^0.4.0` 범위는 0.5 를 받지 못한다.
185
+
186
+ ## 7. 문제 해결
187
+ | 증상 | 원인 | 조치 |
188
+ |---|---|---|
189
+ | `agent.actionsEnabled === false` | 전송 3값(baseUrl·token·domain) 중 누락, 또는 `adminApi.request` 가 함수가 아니거나 `endpoints` 가 객체가 아님 | `.env` 값·참조 이름·바인딩 확인 |
190
+ | `candidatesReady` → `{ok:false, error:"not_configured"}` | `adminApi` 미인식(위와 같은 조건)·전송 3값 누락·`api !== "hub"` | 위 + `api:"hub"` |
191
+ | 카드가 아예 안 보임(에러 없음) | 그 문의에서 아직 초안(`compose`)을 안 받았거나 새로고침으로 offers 가 비었음, 또는 승인된 선택지의 조건에 안 맞음 | [AI 초안] 후 확인 · 운영자에게 선택지 조건 확인 |
192
+ | 카드가 "이 CMS 에 없는 처리" 로 회색 | 승인된 선택지의 API 키가 이 CMS 의 `endpoints` 에 없음 | 운영자와 키 대조(대소문자·이름) |
193
+ | 카드는 있는데 눌러도 실패 (`error:"endpoint_missing"` / `reasonCode:"invalid_execution"`+`message:"params_missing"`) | 실행 사양이 이 CMS 맵·입력값과 어긋남 | 운영자에게 처리 선택지 편집 요청 |
194
+ | 실행 후 "결과가 아직 기록되지 않음" | 허브 보고 네트워크 실패 | 카드의 [결과 다시 보고] — 재실행 아님 |
195
+ | 후보가 공장에 안 보임 | 훅이 아직 안 떴거나 지문 마커로 재발행 생략 | CMS 를 다시 열거나 콘솔 `agent.publishActionCandidates({force:true})` |
@@ -0,0 +1,92 @@
1
+ # cx-agent-hook 참조표 — 스킬이 인용해도 되는 이름 전부 (0.5.x)
2
+
3
+ 이 표에 없는 export·옵션·prop·이벤트는 존재하지 않는다고 본다. 의심되면
4
+ `node_modules/@fcg-labs/cx-agent-hook/{agent,react,vue,element,view}.d.ts` 를 연다.
5
+ `test/skills.test.js` 가 이 표의 export 이름을 실제 모듈과 대조한다(드리프트 가드).
6
+
7
+ ## 서브패스와 export
8
+
9
+ <!-- exports:begin — 형식: `subpath | export1, export2` (시험이 파싱한다) -->
10
+ | 서브패스 | export |
11
+ |---|---|
12
+ | `@fcg-labs/cx-agent-hook` | createCxHook, LOCALES, MESSAGES, normalizeLocale, declineText |
13
+ | `@fcg-labs/cx-agent-hook/agent` | createCxAgent, textOf, notConfiguredResult, koreanGreetingDecorator, LOCALES, MESSAGES, normalizeLocale, ACTION_CLS, actionOffersTree, actionOffersView, buildCandidateSnapshot, executeViaAdminApi, judgeResponse, resolveExecution |
14
+ | `@fcg-labs/cx-agent-hook/react` | AiSuggestPanel, ActionOffersPanel, aiSuggestView |
15
+ | `@fcg-labs/cx-agent-hook/vue` | AiSuggestPanel, ActionOffersPanel, aiSuggestView |
16
+ | `@fcg-labs/cx-agent-hook/element` | TAG, ACTIONS_TAG, defineCxAiSuggest, defineCxActionOffers, aiSuggestView |
17
+ | `@fcg-labs/cx-agent-hook/next` | AiSuggestPanel, ActionOffersPanel, aiSuggestView |
18
+ | `@fcg-labs/cx-agent-hook/astro` | TAG, ACTIONS_TAG, defineCxAiSuggest, defineCxActionOffers, aiSuggestView, setupCxAiSuggest |
19
+ | `@fcg-labs/cx-agent-hook/angular` | TAG, ACTIONS_TAG, defineCxAiSuggest, defineCxActionOffers, aiSuggestView |
20
+ | `@fcg-labs/cx-agent-hook/styles.css` | (CSS — `.fcx-ai-*` 답변 패널 · `.fcx-act-*` 처리 카드, 테마 변수 `--fcx-*`) |
21
+ <!-- exports:end -->
22
+
23
+ `/next`(App Router — 파일 첫 줄에 `"use client"` 를 라이브러리가 선언; `AiSuggestPanel`·**`ActionOffersPanel`** 재수출) ·
24
+ `/astro`·`/angular`(`defineCxAiSuggest`·`TAG`·**`defineCxActionOffers`·`ACTIONS_TAG`** 재수출)도 있다.
25
+ Next App Router 에서 `/react` 를 직접 쓰면 그 파일에 `"use client"` 를 적어야 한다(서버 컴포넌트에서 import 하면 런타임에 깨진다).
26
+ `view.js`·`session.js`·`client.js`·`actions.js` 는 서브패스로 열려 있지 않다 — `/agent` 가 재수출하는
27
+ `ACTION_CLS`·`actionOffersTree`·`actionOffersView`·`buildCandidateSnapshot`·`executeViaAdminApi`·`judgeResponse`·`resolveExecution`
28
+ 만 헤드리스 표면이다(`aiSuggestView` 는 어댑터 서브패스에서).
29
+
30
+ ## createCxAgent(config) 옵션 (`agent.d.ts` CxAgentSetup)
31
+
32
+ | 옵션 | 필수 | 뜻 |
33
+ |---|---|---|
34
+ | `baseUrl` `token` `domain` | 예 (셋 다 없으면 전부 무동작) | 허브 주소 · 브라우저 토큰(도메인 묶임) · 도메인 |
35
+ | `api` | 권장 `"hub"` | 대상. 처리 액션·후보 발행은 `"hub"` 에서만 |
36
+ | `locale` | 선택 | `ko` `en` `ja` `zh-TW` (미지원은 en) |
37
+ | `messages` | 선택 | 문구 override |
38
+ | `bootstrap` | 선택 | 허브 client-config 병합 |
39
+ | `source` | 선택(기본 `"cms"`) | 인그레스 채널명 |
40
+ | `draftDecorators` | 선택 | 초안 후처리 사슬 (기본 한국어 호칭) |
41
+ | `storage` | 선택 | `{enabled, ttlMs, maxSessions, prefix, impl, actionsImpl}` |
42
+ | **`adminApi`** | 처리 카드에 필수 | `{ request, endpoints, map?, onAuthExpired? }` — 아래 |
43
+ | `onError` | 선택 | 후킹 실패 기록 콜백 (화면을 막지 않는다) |
44
+
45
+ ### adminApi
46
+ | 키 | 형 | 뜻 |
47
+ |---|---|---|
48
+ | `request` | `({method, url, path?, query?, data?}) => Promise<axios응답 \| 본문>` | 이 CMS 의 기존 요청 헬퍼 참조. `method` 소문자, `url` 은 `endpoints[key]` 원문(`:param` 포함), `path` 치환·`query`·`data` 는 헬퍼가 처리 |
49
+ | `endpoints` | `Record<KEY, urlTemplate>` | 엔드포인트 맵. 처리 카드 후보·실행 URL 원천 |
50
+ | `map` | `Record<method, Record<urlTemplate, unknown>>` | 메서드 화이트리스트(선택) — 후보 메서드 추출 |
51
+ | `onAuthExpired` | `(body) => void` | HTTP 401/403 이거나, 2xx 본문의 코드가 카탈로그 `response_rule.auth_expired_codes` 에 걸릴 때 호출(인자 = 응답 본문) |
52
+
53
+ ## agent (CxAgent)
54
+ `enabled` · `ready` · `flags` · `locale` · `messages` · `declineText(reason)` · `onConfigChange(fn)` ·
55
+ `session(externalId, {persist?})` · `requestAnswer(inquiry, context?)` · `requestInvestigation(externalId, {userId?, subUserId?})` ·
56
+ `investigationStatus(externalId)` · **`actionsEnabled`** · **`candidatesReady`** · **`publishActionCandidates({force?})`** · `inquirySent({externalId, inquiry, reply?, agent?, meta?})`
57
+
58
+ ## session (InquirySession = agent.session(id))
59
+ 답변: `attachUi({getDraft?, setDraft, setStatus?}): this` · `detachUi(): this` · `restore()` · `remember(text)` ·
60
+ `compose({inquiry, context?, customerName?, confirmOverwrite?, idleTimeoutMs?, overallTimeoutMs?}) → {promise, abort}` ·
61
+ `noteAdopted(id)` · `clearAdopted()` · `answerSent(finalText, agent?)` · `scored(score, agent?)` · `edited(finalText, agent?)` · `discarded(agent?, note?)`
62
+ 읽기: `externalId` · `state` · `draft` · `adoptedAnswerId`
63
+ 처리 카드: `offers` · `attachActionUi({setOffers}): void`(**세션당 sink 1개 — 나중 것이 앞 것을 덮고, 한쪽 detach 가 다른 쪽도 끊는다**) · `detachActionUi()` · `pendingActions()` ·
64
+ `executeAction(offer, {actorClaimed?}) → {requestId, ok, state, reasonCode, message, reported, error}` — 선점 전 거절
65
+ (`error: not_configured|admin_api_missing|endpoint_missing`)이면 `ok:false, state:""` 이고 `requestId` 가 없다 ·
66
+ `resendResult(requestId)` · `flushUnreportedActions()` · `refreshActionLocks()`
67
+ **offers 가 채워지는 유일한 경로는 그 세션의 `compose()` 응답**(`suggested_actions`) — 초안을 한 번도 안 받은 문의엔
68
+ 카드가 없고, 새로고침하면 offers 는 비고(pending 만 localStorage 에 남는다) 다시 `compose` 해야 카드가 온다.
69
+
70
+ ## 어댑터
71
+ | 어댑터 | 답변 패널 | 처리 카드 |
72
+ |---|---|---|
73
+ | React (`/react`) | `<AiSuggestPanel hook inquiry context? onAdopt className?>` (0.2 훅용) | `<ActionOffersPanel session agent actorClaimed? onResult? className?>` |
74
+ | Vue 3 (`/vue`) | `<AiSuggestPanel :hook :inquiry :context @adopt>` | `<ActionOffersPanel :session :agent :actor-claimed @result>` |
75
+ | element (`/element`) | `defineCxAiSuggest()` → `<cx-ai-suggest>` (`el.hook` `el.inquiry` `el.context`, 이벤트 `cx-adopt`) | `defineCxActionOffers()` → `<cx-action-offers>` (`el.agent` `el.session` `el.actorClaimed`, 이벤트 `cx-action-result` detail=ActionResult) |
76
+
77
+ 처리 카드 상태(컴포넌트가 처리): 실행 가능 · 확인 2단(고위험은 체크 1번 더, aria-pressed 토글 버튼) ·
78
+ 실행 중 · 완료 · 실패(재시도 가능) · 확인 필요(unknown, 재실행 없음) · 이미 처리됨(잠김) ·
79
+ 이 CMS 에 없는 처리(비활성) · 결과 다시 보고(미보고). 문구 키 `ui_action_*` 17종(4 로케일).
80
+
81
+ ## 상태·값
82
+ - `ActionResult.state`: `received` | `succeeded` | `failed` | `unknown` (선점 전 거절은 `""`); `reported=false` 면 `resendResult`.
83
+ 실패 사유 위치: `error:"endpoint_missing"`(이 CMS 에 키 없음) · `reasonCode:"invalid_execution"` + `message:"params_missing"`(입력값 치환 실패) ·
84
+ `reasonCode:"business"|"auth_expired"|"http_client_error"`(CMS 응답 판정) · `state:"unknown"` + `reasonCode:"transport"|"http_server_error"`.
85
+ - `context` 값은 전부 문자열로 정규화되고 **빈 값은 버려진다**(`0`·`""` 키 소실) — 처리 카드 입력값의 원천이므로 값이 있는 키만 문자열로.
86
+ - `storage: { enabled: false }` 는 세션 영속뿐 아니라 **처리 원장(ActionLedger)도 끈다** → `pendingActions()` 빈 배열·미보고 재보고 불가. 공용 PC 정책으로 꺼 둔 프로젝트는 `storage.actionsImpl` 로 원장만 살릴지 결정한다.
87
+ - `.env` 키(Vite 예): `VITE_CX_HUB_URL` `VITE_CX_HUB_BROWSER_TOKEN` `VITE_CX_DOMAIN` — 접두는 번들러 규칙대로.
88
+ - 저장: 세션 sessionStorage(TTL 30분) · 처리 원장 localStorage(종단 24h) · 후보 발행 마커 `cx-agent-catalog:<domain>`(24h).
89
+ - 재시도: 실행 0(선점만 미도달 확정 시 1회 재전송) — 소비처가 재시도를 덧대지 않는다.
90
+
91
+ ## 파일
92
+ `package.json`(files 화이트리스트) · `README.md` · `MIGRATION.md` · `*.d.ts` · `styles.css` · `skills/cx-agent-hook-setup/`(이 문서).