@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/index.js CHANGED
@@ -1,21 +1,26 @@
1
1
  /**
2
- * @fcg-labs/cx-agent-hook — FCG CX Agent 후킹 SDK (공개 표면).
2
+ * @fcg-labs/cx-agent-hook — FCG CX Agent 후킹 SDK (0.2.x 호환 공개 표면).
3
3
  *
4
- * 소비처(고객사 관리자 화면)가 닿는 것은 이 파일과 `./react` 뿐이다.
4
+ * 소비처(고객사 관리자 화면)가 닿는 것은 이 파일과 어댑터 서브패스뿐이다.
5
5
  * 전송 계층(`CxAgentClient`)은 `client.js` 에 있고 **밖으로 내보내지 않는다** —
6
6
  * 열어 두면 새 능력을 붙일 때 자연히 그리로 내려가고, 그 순간 `answer_id`
7
7
  * 수명 관리 같은 제품 지식이 다시 고객사 코드로 흩어진다. 능력이 모자라면
8
- * 저수준을 노출하는 게 아니라 **여기 표면을 채운다.**
8
+ * 저수준을 노출하는 게 아니라 **표면을 채운다.**
9
9
  *
10
- * ① 답변 제안 수신 requestAnswer
10
+ * ① 답변 제안 수신 requestAnswer · composeDraft
11
11
  * ② 상담사 판단 후킹 answerSent · scored · edited · discarded
12
12
  * ③ 문의·답변 쌍 적재 inquirySent ★ 이게 빠지면 아무것도 안 쌓인다
13
13
  *
14
+ * 0.3.0: 구현은 `agent.js`(CxAgent) + `session.js`(InquirySession) 로
15
+ * 객체화됐다. 이 루트 표면은 그 위의 호환층(compat.js)이다 — 문의별 세션
16
+ * 상태·초안 영속이 필요하면 `@fcg-labs/cx-agent-hook/agent` 로 올라간다.
17
+ *
14
18
  * 원칙: 의존성 0 (내장 fetch), 어떤 메서드도 throw 하지 않는다.
15
19
  */
16
- import { CxAgentClient } from "./client.js";
17
20
  import { LOCALES, MESSAGES, normalizeLocale, resolveMessages } from "./locales.js";
21
+ import { textOf } from "./agent.js";
18
22
 
23
+ export { createCxHook } from "./compat.js";
19
24
  export { LOCALES, MESSAGES, normalizeLocale };
20
25
 
21
26
  /** 사유 코드 → 상담사가 읽을 평문.
@@ -26,326 +31,3 @@ export { LOCALES, MESSAGES, normalizeLocale };
26
31
  export function declineText(reason, locale) {
27
32
  return textOf(resolveMessages(locale), reason);
28
33
  }
29
-
30
- /** 문구 한 벌에서 사유 하나 꺼내기. 기계 코드를 화면에 노출하지 않는다. */
31
- function textOf(messages, reason) {
32
- const known = messages[reason];
33
- if (known) return known;
34
- if (String(reason || "").startsWith("http_")) return messages.http_error;
35
- return messages.unknown;
36
- }
37
-
38
- const NOT_CONFIGURED = {
39
- ok: false, answered: false, answer: "", answerId: null,
40
- evidence: [], declinedReason: "not_configured", raw: {},
41
- };
42
-
43
- /** 후킹 한 벌을 만든다 — 미설정이면 전부 무동작.
44
- *
45
- * 소비하는 쪽이 써야 할 것은 **주소·토큰·도메인 세 값을 자기 빌드 방식으로 읽어
46
- * 넘기는 일**뿐이다. 미설정 판정, 널 가드, 실패 시 돌려줄 모양, 사유 문구,
47
- * 채택 식별자의 수명은 전부 여기에 있다.
48
- */
49
- export function createCxHook(config = {}) {
50
- const {
51
- baseUrl, token, domain, api = "hub", onError,
52
- // 화면 언어. 소비처가 이미 아는 값이라 setup 에서 한 번 넘기면 끝이고,
53
- // 이후 문구는 전부 훅이 낸다 — 고객사가 사유별 문구를 알 필요가 없다.
54
- locale, messages: messageOverrides,
55
- // 중앙 설정 부트스트랩 (허브 GET client-config) — 기본 꺼짐(후방호환).
56
- // 켜면 locale·문구·기능 플래그를 허브 발행분으로 병합한다. **명시 인자가
57
- // 항상 이긴다** — 로컬이 정한 값은 원격이 못 덮는다. 전송 3요소(baseUrl·
58
- // token·domain)는 부트스트랩의 닭-달걀이라 중앙화 대상이 아니다.
59
- bootstrap = false,
60
- ...rest
61
- } = config;
62
- const messages = resolveMessages(locale, messageOverrides);
63
- let flags = {};
64
- const client =
65
- baseUrl && token && domain
66
- ? new CxAgentClient({
67
- baseUrl, token, domain, api,
68
- // 후킹 실패는 CS 업무와 무관 — 기록만 하고 화면을 막지 않는다.
69
- onError:
70
- onError ||
71
- ((err, ctx) => console.warn("[cx-agent-hook]", ctx.op, err.message)),
72
- ...rest,
73
- })
74
- : null;
75
-
76
- // 상담사가 어느 제안을 에디터에 넣었는지. **이 기록이 gold 쌍의 유일한 근거다**
77
- // — 발송된 최종 문구가 어느 answer_id 의 교정본인지 잇는 값이라, 여기가 비면
78
- // 후킹은 일어나도 "AI 초안 → 사람 최종본" 델타가 성립하지 않는다.
79
- // 고객사가 들고 다니게 하면 지우는 시점(문의 전환·발송 완료)까지 남의 코드에
80
- // 흩어진다. 라이브러리가 소유한다.
81
- let adoptedAnswerId = null;
82
-
83
- /** 채택된 제안에 대한 교정 후킹. 채택이 없었으면 조용히 무동작.
84
- *
85
- * `consume` 이 true 면 보낸 뒤 기록을 비운다 — 발송·폐기는 그 제안에 대한
86
- * 마지막 판단이라 두 번 세면 안 되고, 점수·수정은 발송 전에 여러 번 올 수 있다.
87
- */
88
- const emit = (send, consume) => {
89
- const id = adoptedAnswerId;
90
- if (consume) adoptedAnswerId = null;
91
- if (!client || !id) return;
92
- send(id);
93
- };
94
-
95
- const applyRemoteConfig = (remote) => {
96
- if (!remote || typeof remote !== "object") return;
97
- if (remote.locale || remote.messages) {
98
- // 원격 문구 위에 로컬 override 재적용 — 로컬 우선 규칙을 병합 순서로 강제
99
- const next = resolveMessages(locale || remote.locale, {
100
- ...(remote.messages || {}),
101
- ...(messageOverrides || {}),
102
- });
103
- Object.assign(messages, next);
104
- }
105
- if (remote.flags && typeof remote.flags === "object") {
106
- flags = { ...remote.flags };
107
- }
108
- };
109
-
110
- const fetchRemoteConfig = async () => {
111
- const doFetch = rest.fetchImpl || (typeof fetch !== "undefined" ? fetch : null);
112
- if (!doFetch) return;
113
- const cacheKey = `cx-agent-config:${domain}`;
114
- try {
115
- if (typeof sessionStorage !== "undefined") {
116
- const cached = JSON.parse(sessionStorage.getItem(cacheKey) || "null");
117
- if (cached && Date.now() - cached.at < 5 * 60 * 1000) {
118
- applyRemoteConfig(cached.config);
119
- return;
120
- }
121
- }
122
- } catch { /* 캐시 실패는 무시 — 네트워크로 진행 */ }
123
- const res = await doFetch(
124
- `${String(baseUrl).replace(/\/$/, "")}/v1/domains/${domain}/client-config`,
125
- { headers: { Authorization: `Bearer ${token}` } },
126
- );
127
- if (!res.ok) return; // 404(미발행) 포함 — 정적 설정으로 동작 (fail-soft)
128
- const remote = await res.json();
129
- applyRemoteConfig(remote);
130
- try {
131
- if (typeof sessionStorage !== "undefined") {
132
- sessionStorage.setItem(
133
- cacheKey, JSON.stringify({ at: Date.now(), config: remote }),
134
- );
135
- }
136
- } catch { /* 저장 실패 무시 */ }
137
- };
138
-
139
- // 부트스트랩은 백그라운드 — 화면을 막지 않는다. 기다리고 싶은 소비처만
140
- // ready 를 await 한다 (실패도 resolve — 훅은 항상 동작 가능 상태).
141
- const ready = (bootstrap && client && api === "hub")
142
- ? fetchRemoteConfig().catch(() => {})
143
- : Promise.resolve();
144
-
145
- return {
146
- /** 세 값이 다 있으면 true */
147
- enabled: Boolean(client),
148
- /** 부트스트랩 완료 신호 — bootstrap 미사용 시 즉시 resolve */
149
- ready,
150
- /** 중앙 발행 기능 플래그 — 부트스트랩 전/미발행이면 빈 객체 */
151
- get flags() { return flags; },
152
- /** setup 에서 정해진 화면 언어 (정규화된 값) */
153
- locale: normalizeLocale(locale),
154
- /** 이 훅의 문구 한 벌 — 패널이 UI 라벨까지 여기서 가져간다 */
155
- messages,
156
- /** 사유 코드 → 이 훅의 언어로 된 평문 */
157
- declineText(reason) {
158
- return textOf(messages, reason);
159
- },
160
-
161
- /** 답변 제안 요청 — throw 하지 않음.
162
- * context: 문의에 붙는 고객 상황(기기·버전·융합 키) — 선택. */
163
- requestAnswer(inquiry, context) {
164
- if (!client || !inquiry) return Promise.resolve(NOT_CONFIGURED);
165
- return client.getAnswer(inquiry, context);
166
- },
167
-
168
- /** 조사 요청 (E-8) — "이 케이스 깊이 조사해줘" 트리거. 큐 적재만 하고 즉시
169
- * 돌아온다 — 결과는 investigationStatus 로 폴링, 리포트 검수는 공장 웹. */
170
- requestInvestigation(externalId, identity) {
171
- if (!client) return Promise.resolve({ ok: false, status: "", error: "not_configured" });
172
- return client.requestInvestigation(externalId, identity);
173
- },
174
-
175
- /** 조사 상태 (E-8) — {status: none|queued|running|succeeded|failed, jobId} */
176
- investigationStatus(externalId) {
177
- if (!client) return Promise.resolve({ ok: false, status: "", error: "not_configured" });
178
- return client.investigationStatus(externalId);
179
- },
180
-
181
- /** 초안 직주입 스트리밍 — 답변 에디터에 AI 초안을 직접 흘려 쓴다.
182
- *
183
- * 패널·채택 버튼 없는 흐름의 정본이다: 소비처는 에디터 접근자(getDraft·
184
- * setDraft)와 상태 표시(setStatus)만 배선하고, 나머지 제품 판단 —
185
- * 덮어쓰기 확인, 교정 재시작 처리, 실패 시 원복, 문구, **자동 채택 귀속**
186
- * (직주입 = 채택이므로 성공 시 noteAdopted 를 훅이 스스로 부른다) — 은
187
- * 전부 여기 있다. answerSent 의 consume 의미론은 그대로다.
188
- *
189
- * @param {object} opts
190
- * @param {string} opts.inquiry
191
- * @param {Record<string,string>} [opts.context]
192
- * @param {()=>string} [opts.getDraft] 현재 초안 (덮어쓰기 가드용)
193
- * @param {(text:string)=>void} opts.setDraft 초안 전체 치환 (누적 스냅샷)
194
- * @param {(text:string)=>void} [opts.setStatus] 한 줄 상태 ("" = 지움)
195
- * @param {()=>boolean} [opts.confirmOverwrite] 초안이 비어있지 않을 때 확인
196
- * @returns {{promise: Promise<object>, abort: ()=>void}}
197
- */
198
- composeDraft({ inquiry, context, getDraft, setDraft, setStatus,
199
- confirmOverwrite, customerName } = {}) {
200
- const status = (text) => { if (setStatus) setStatus(text || ""); };
201
- const noop = { promise: Promise.resolve(NOT_CONFIGURED), abort: () => {} };
202
- if (!client || !inquiry || typeof setDraft !== "function") {
203
- status(textOf(messages, "not_configured"));
204
- return noop;
205
- }
206
- const existing = typeof getDraft === "function"
207
- ? String(getDraft() || "") : "";
208
- if (existing.trim() && !(confirmOverwrite && confirmOverwrite())) {
209
- // 상담사가 쓰던 초안이 우선한다 — 조용히 덮지 않는다.
210
- return { promise: Promise.resolve({ ...NOT_CONFIGURED, declinedReason: "" }),
211
- abort: () => {} };
212
- }
213
- // 새 스트림 = 이전 채택의 **교체 시도** (0.2.3). 확정(성공) 전까지
214
- // 폐기하지 않고 보류한다 — 즉시 null 로 지우던 침묵 소거는 ① 실패 시
215
- // 원복이 텍스트만 되고 채택 id 는 유실돼 verbatim 발송이 원장에서
216
- // 빠졌고 ② 상담사가 명백히 버린 이전 제안이 기각 신호 없이 영구
217
- // 미확인으로 남았다 (2026-08-10 적대 검증).
218
- const supersededId = adoptedAnswerId;
219
- adoptedAnswerId = null; // 스트림 중 발송이 낡은 id 로 귀속되는 것 방지
220
-
221
- // 호칭 개인화 — 0.2.2: 이름을 context 로도 보낸다. 서버가 사설
222
- // 서빙(AI_CS_LLM_PRIVATE)이면 인사말을 처음부터 이름으로 굽고,
223
- // **저장은 하지 않는다**(로그에는 name_provided 마커만 — 서버 계약).
224
- // 아래 클라 치환은 안전망으로 유지: 구 서버·클라우드 폴백처럼 이름
225
- // 없이 "안녕하세요 고객님"으로 온 초안에만 작동하고, 서버가 이미
226
- // 이름을 넣었으면 패턴이 안 맞아 no-op 이다.
227
- const name = String(customerName || "").trim();
228
- const contextWithName = name
229
- ? { ...(context || {}), customerName: name }
230
- : context;
231
- const personalize = (text) =>
232
- name
233
- ? String(text).replace(
234
- /안녕하세요[,]?\s*고객님/,
235
- (m) => m.replace("고객님", `${name} 고객님`),
236
- )
237
- : text;
238
-
239
- const controller = new AbortController();
240
- let aborted = false;
241
- let accumulated = "";
242
- let pending = null;
243
- const flushDraft = () => { pending = null; setDraft(personalize(accumulated)); };
244
- const queueDraft = () => {
245
- // trailing 스로틀 — 청크마다 리렌더하면 큰 화면이 버벅인다
246
- if (pending === null) pending = setTimeout(flushDraft, 80);
247
- };
248
- const clearPending = () => {
249
- if (pending !== null) { clearTimeout(pending); pending = null; }
250
- };
251
-
252
- status(messages.ui_requesting);
253
- setDraft("");
254
- const promise = client.getAnswerStream(inquiry, contextWithName, {
255
- signal: controller.signal,
256
- onDelta(text) { accumulated += text; queueDraft(); },
257
- onRestart() {
258
- // 계약 위반 교정 — 지금까지 보인 초안은 폐기본이다
259
- clearPending();
260
- accumulated = "";
261
- setDraft("");
262
- status(messages.ui_correcting);
263
- },
264
- onStage(phase) {
265
- if (phase === "generating") status(messages.ui_requesting);
266
- },
267
- }).then((result) => {
268
- clearPending();
269
- if (aborted) {
270
- // 소비자 주도 중단 (문의 전환 등) — 화면은 소비자가 이미 재구성했다.
271
- // 여기서 원복하면 **이전 문의의 초안이 새 문의 에디터에 주입**된다
272
- // (0.2.3 — A 답변이 B 로 발송될 수 있던 오염 경로). 아무것도 안 만진다.
273
- return result;
274
- }
275
- if (result.answered) {
276
- setDraft(personalize(result.answer)); // 정본 확정 (strip+호칭 반영)
277
- const newId = result.answerId ?? null;
278
- if (supersededId && supersededId !== newId) {
279
- // 교체 확정 — 이전 제안은 기각으로 원장에 남긴다 (침묵 소거 금지).
280
- // fire-and-forget: 실패해도 발송 UX 무간섭.
281
- client.discarded(supersededId, "", "redraft");
282
- }
283
- adoptedAnswerId = newId;
284
- status("");
285
- } else {
286
- // 실패·거절 — 미완성 텍스트를 에디터에 남기지 않고 원래 초안 복원.
287
- // 채택 기록도 함께 원복 — 텍스트만 되돌리면 복원된 이전 초안의
288
- // verbatim 발송이 원장에서 빠진다 (0.2.3 비대칭 수정).
289
- setDraft(existing);
290
- adoptedAnswerId = supersededId;
291
- status(textOf(messages, result.declinedReason || "unknown"));
292
- }
293
- return result;
294
- });
295
- return {
296
- promise,
297
- abort: () => {
298
- // 중단 = 화면 상태는 소비자 소유, 원장 상태는 요청 전으로 복귀 —
299
- // 직후 소비자의 폐기 훅(discarded)이 이전 채택 id 로 발화할 수 있다.
300
- aborted = true;
301
- adoptedAnswerId = supersededId;
302
- clearPending();
303
- controller.abort();
304
- },
305
- };
306
- },
307
-
308
- /** 제안을 에디터에 넣었다 (패널이 부른다) */
309
- noteAdopted(answerId) {
310
- adoptedAnswerId = answerId || null;
311
- },
312
- /** 채택 기록 폐기 — 다른 문의로 옮겼을 때 */
313
- clearAdopted() {
314
- adoptedAnswerId = null;
315
- },
316
- /** 지금 채택된 제안이 있나 (표시·검증용) */
317
- get adoptedAnswerId() {
318
- return adoptedAnswerId;
319
- },
320
-
321
- /** 답변 발송됨 — 채택본이었다면 gold 쌍으로 후킹 (fire-and-forget).
322
- *
323
- * 채택 없이 상담사가 직접 쓴 답변이면 아무 일도 안 한다. 한 번 보내면
324
- * 기록을 비운다 — 같은 문의를 다시 발송해도 중복 교정이 쌓이지 않는다.
325
- */
326
- answerSent(finalText, agent) {
327
- emit((id) => client.sent(id, finalText, agent), true);
328
- },
329
- /** 제안 품질 점수 1~5 (발송 전에 여러 번 올 수 있어 기록을 비우지 않는다) */
330
- scored(score, agent) {
331
- emit((id) => client.scored(id, score, agent), false);
332
- },
333
- /** 제안을 고쳐 썼다 — 초안↔최종본 델타 (발송 전 중간 저장 가능) */
334
- edited(finalText, agent) {
335
- emit((id) => client.edited(id, finalText, agent), false);
336
- },
337
- /** 제안을 버렸다 + 이유. 그 제안에 대한 마지막 판단이라 기록을 비운다. */
338
- discarded(agent, note) {
339
- emit((id) => client.discarded(id, agent, note), true);
340
- },
341
-
342
- /** 문의+상담사 최종답변 쌍 적재 (fire-and-forget, external_id 멱등).
343
- *
344
- * AI 제안을 안 써도 이것만 붙으면 광물이 쌓인다 — 인입의 유일한 통로.
345
- */
346
- inquirySent({ externalId, inquiry, reply, agent, meta } = {}) {
347
- if (!client || !externalId || !inquiry) return;
348
- client.logInquiry({ externalId, inquiry, reply, agent, meta });
349
- },
350
- };
351
- }
package/locales.js CHANGED
@@ -31,6 +31,20 @@ const en = {
31
31
  ui_adopt: "Insert into editor",
32
32
  ui_evidence: "Sources",
33
33
  ui_correcting: "Fixing phrasing — rewriting…",
34
+ ui_action_execute: "Process",
35
+ ui_action_confirm_default: "Run this action?",
36
+ ui_action_confirm_ok: "Yes, run",
37
+ ui_action_confirm_cancel: "Cancel",
38
+ ui_action_ack: "I understand this cannot be undone",
39
+ ui_action_running: "Processing…",
40
+ ui_action_succeeded: "Done",
41
+ ui_action_failed: "Not applied",
42
+ ui_action_unknown: "Could not confirm whether this was applied — check again before retrying.",
43
+ ui_action_reconcile: "Check again",
44
+ ui_action_disabled: "Actions are not enabled for this seat.",
45
+ ui_action_risk_low: "low risk",
46
+ ui_action_risk_medium: "medium risk",
47
+ ui_action_risk_high: "high risk",
34
48
  ui_overwrite_confirm: "Replace your current draft with the AI draft?",
35
49
  // 거절·오류 사유
36
50
  not_configured: "AI reply suggestions are not connected yet.",
@@ -57,6 +71,20 @@ const ko = {
57
71
  ui_adopt: "에디터에 넣기",
58
72
  ui_evidence: "근거",
59
73
  ui_correcting: "표현 교정 중 — 다시 쓰는 중...",
74
+ ui_action_execute: "처리하기",
75
+ ui_action_confirm_default: "이 처리를 진행할까요?",
76
+ ui_action_confirm_ok: "네, 진행",
77
+ ui_action_confirm_cancel: "취소",
78
+ ui_action_ack: "되돌릴 수 없음을 확인했습니다",
79
+ ui_action_running: "처리 중…",
80
+ ui_action_succeeded: "처리 완료",
81
+ ui_action_failed: "처리되지 않음",
82
+ ui_action_unknown: "실행 여부를 확인하지 못했습니다 — 다시 누르지 말고 먼저 확인하세요.",
83
+ ui_action_reconcile: "다시 확인",
84
+ ui_action_disabled: "이 계정에는 처리 기능이 켜져 있지 않습니다.",
85
+ ui_action_risk_low: "위험 낮음",
86
+ ui_action_risk_medium: "위험 보통",
87
+ ui_action_risk_high: "위험 높음",
60
88
  ui_overwrite_confirm: "작성 중인 답변을 지우고 AI 초안으로 바꿀까요?",
61
89
  not_configured: "AI 답변 제안이 아직 연결되지 않았습니다.",
62
90
  unsupported_api: "AI 답변 제안이 아직 연결되지 않았습니다.",
@@ -78,6 +106,20 @@ const ja = {
78
106
  ui_adopt: "エディタに挿入",
79
107
  ui_evidence: "根拠",
80
108
  ui_correcting: "表現を修正中 — 書き直しています…",
109
+ ui_action_execute: "処理する",
110
+ ui_action_confirm_default: "この処理を実行しますか?",
111
+ ui_action_confirm_ok: "はい、実行",
112
+ ui_action_confirm_cancel: "キャンセル",
113
+ ui_action_ack: "元に戻せないことを確認しました",
114
+ ui_action_running: "処理中…",
115
+ ui_action_succeeded: "処理完了",
116
+ ui_action_failed: "未処理",
117
+ ui_action_unknown: "実行の有無を確認できませんでした — 再度押さずに先に確認してください。",
118
+ ui_action_reconcile: "再確認",
119
+ ui_action_disabled: "このアカウントでは処理機能が有効になっていません。",
120
+ ui_action_risk_low: "リスク低",
121
+ ui_action_risk_medium: "リスク中",
122
+ ui_action_risk_high: "リスク高",
81
123
  ui_overwrite_confirm: "作成中の回答を消してAI下書きに置き換えますか?",
82
124
  not_configured: "AI 返信案はまだ接続されていません。",
83
125
  unsupported_api: "AI 返信案はまだ接続されていません。",
@@ -99,6 +141,20 @@ const zhTW = {
99
141
  ui_adopt: "插入編輯器",
100
142
  ui_evidence: "依據",
101
143
  ui_correcting: "正在修正表述 — 重新撰寫中…",
144
+ ui_action_execute: "處理",
145
+ ui_action_confirm_default: "要執行這項處理嗎?",
146
+ ui_action_confirm_ok: "是,執行",
147
+ ui_action_confirm_cancel: "取消",
148
+ ui_action_ack: "我已確認此操作無法復原",
149
+ ui_action_running: "處理中…",
150
+ ui_action_succeeded: "處理完成",
151
+ ui_action_failed: "未套用",
152
+ ui_action_unknown: "無法確認是否已執行 — 請先確認再重試。",
153
+ ui_action_reconcile: "再次確認",
154
+ ui_action_disabled: "此帳號未啟用處理功能。",
155
+ ui_action_risk_low: "風險低",
156
+ ui_action_risk_medium: "風險中",
157
+ ui_action_risk_high: "風險高",
102
158
  ui_overwrite_confirm: "要清除目前草稿並以 AI 草稿取代嗎?",
103
159
  not_configured: "AI 回覆建議尚未連接。",
104
160
  unsupported_api: "AI 回覆建議尚未連接。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcg-labs/cx-agent-hook",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -10,6 +10,10 @@
10
10
  "types": "./index.d.ts",
11
11
  "import": "./index.js"
12
12
  },
13
+ "./agent": {
14
+ "types": "./agent.d.ts",
15
+ "import": "./agent.js"
16
+ },
13
17
  "./react": {
14
18
  "types": "./react.d.ts",
15
19
  "import": "./react.js"
@@ -54,8 +58,13 @@
54
58
  "files": [
55
59
  "index.js",
56
60
  "index.d.ts",
61
+ "agent.js",
62
+ "agent.d.ts",
63
+ "session.js",
64
+ "compat.js",
57
65
  "client.js",
58
66
  "client.d.ts",
67
+ "MIGRATION.md",
59
68
  "view.js",
60
69
  "view.d.ts",
61
70
  "locales.js",
package/react.js CHANGED
@@ -19,11 +19,25 @@
19
19
  */
20
20
  import { createElement as h, useCallback, useEffect, useRef, useState } from "react";
21
21
 
22
- import { aiSuggestView, CLS } from "./view.js";
22
+ import { aiSuggestTree, aiSuggestView, CLS } from "./view.js";
23
23
 
24
24
  // 표시 판단은 프레임워크 중립 코어가 갖는다 — Vue·웹 컴포넌트와 같은 것을 쓴다.
25
25
  export { aiSuggestView };
26
26
 
27
+ /** 트리 노드 → React 엘리먼트 — 구조는 view.aiSuggestTree 가 소유한다. */
28
+ function renderNode(node, actions, key) {
29
+ const props = { key, className: node.cls };
30
+ if (node.tag === "button") {
31
+ props.type = node.type || "button";
32
+ props.disabled = Boolean(node.disabled);
33
+ }
34
+ if (node.action && actions[node.action]) props.onClick = actions[node.action];
35
+ const children = node.children
36
+ ? node.children.map((c, i) => renderNode(c, actions, i))
37
+ : node.text;
38
+ return h(node.tag, props, children);
39
+ }
40
+
27
41
  /**
28
42
  * @param {object} props
29
43
  * @param {object} props.hook createCxHook() 결과
@@ -66,7 +80,7 @@ export function AiSuggestPanel({ hook, inquiry, context, onAdopt, className }) {
66
80
 
67
81
  if (!hook || !hook.enabled) return null;
68
82
 
69
- const view = aiSuggestView({
83
+ const tree = aiSuggestTree({
70
84
  state, result, declineText: hook.declineText, messages: hook.messages,
71
85
  });
72
86
 
@@ -78,40 +92,7 @@ export function AiSuggestPanel({ hook, inquiry, context, onAdopt, className }) {
78
92
  // 행 선택이 토글되면 제안이 초기화된다.
79
93
  onClick: (event) => event.stopPropagation(),
80
94
  },
81
- h(
82
- "div",
83
- { className: CLS.head },
84
- h(
85
- "button",
86
- {
87
- type: "button",
88
- className: CLS.button,
89
- disabled: view.buttonDisabled,
90
- onClick: request,
91
- },
92
- view.buttonLabel,
93
- ),
94
- view.showAdopt &&
95
- h(
96
- "button",
97
- { type: "button", className: CLS.adopt, onClick: adopt },
98
- view.adoptLabel,
99
- ),
100
- ),
101
- view.body.kind === "answer" &&
102
- h(
103
- "div",
104
- { className: CLS.body },
105
- h("div", { className: CLS.answer }, view.body.text),
106
- view.body.evidence.length > 0 &&
107
- h(
108
- "div",
109
- { className: CLS.evidence },
110
- `${view.body.evidenceLabel}: ${view.body.evidence.join(" · ")}`,
111
- ),
112
- ),
113
- view.body.kind === "declined" &&
114
- h("div", { className: CLS.declined }, view.body.text),
95
+ tree.children.map((node, i) => renderNode(node, { request, adopt }, i)),
115
96
  );
116
97
  }
117
98