@fcg-labs/cx-agent-hook 0.2.2 → 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/element.js CHANGED
@@ -24,21 +24,30 @@
24
24
  * 클래스를 모듈 최상위에서 만들지 않는 이유: `HTMLElement` 가 없는 곳(SSR·Node)
25
25
  * 에서 import 만 해도 죽는다. 등록 시점에 만든다.
26
26
  */
27
- import { aiSuggestView, CLS } from "./view.js";
27
+ import { aiSuggestTree, aiSuggestView, CLS } from "./view.js";
28
28
 
29
29
  export { aiSuggestView };
30
30
 
31
31
  /** 기본 태그 이름 */
32
32
  export const TAG = "cx-ai-suggest";
33
33
 
34
- function el(tag, className, ...children) {
35
- const node = document.createElement(tag);
36
- if (className) node.className = className;
37
- for (const c of children) {
38
- if (c === null || c === false || c === undefined) continue;
39
- node.append(c);
34
+ /** 트리 노드 DOM — 구조는 view.aiSuggestTree 가 소유한다. */
35
+ function renderNode(node, actions) {
36
+ const dom = document.createElement(node.tag);
37
+ if (node.cls) dom.className = node.cls;
38
+ if (node.tag === "button") {
39
+ dom.type = node.type || "button";
40
+ dom.disabled = Boolean(node.disabled);
40
41
  }
41
- return node;
42
+ if (node.action && actions[node.action]) {
43
+ dom.addEventListener("click", actions[node.action]);
44
+ }
45
+ if (node.children) {
46
+ for (const c of node.children) dom.append(renderNode(c, actions));
47
+ } else if (node.text !== undefined) {
48
+ dom.textContent = node.text;
49
+ }
50
+ return dom;
42
51
  }
43
52
 
44
53
  function createClass() {
@@ -100,49 +109,22 @@ function createClass() {
100
109
  #render() {
101
110
  this.replaceChildren();
102
111
  if (!this.#hook || !this.#hook.enabled) return;
103
- const v = aiSuggestView({
112
+ const tree = aiSuggestTree({
104
113
  state: this.#state,
105
114
  result: this.#result,
106
115
  declineText: this.#hook.declineText,
107
116
  messages: this.#hook.messages,
108
117
  });
109
-
110
- const button = el("button", CLS.button);
111
- button.type = "button";
112
- button.disabled = v.buttonDisabled;
113
- button.textContent = v.buttonLabel;
114
- button.addEventListener("click", () => this.#request());
115
-
116
- const head = el("div", CLS.head, button);
117
- if (v.showAdopt) {
118
- const adopt = el("button", CLS.adopt);
119
- adopt.type = "button";
120
- adopt.textContent = v.adoptLabel;
121
- adopt.addEventListener("click", () => this.#adopt());
122
- head.append(adopt);
123
- }
124
-
125
- const root = el("div", CLS.root, head);
118
+ const actions = {
119
+ request: () => this.#request(),
120
+ adopt: () => this.#adopt(),
121
+ };
122
+ const root = document.createElement("div");
123
+ root.className = CLS.root;
126
124
  // 이 패널은 보통 "클릭하면 선택되는" 문의 행 안에 놓인다. 버튼을 누르려다
127
125
  // 행 선택이 토글되면 제안이 초기화된다.
128
126
  root.addEventListener("click", (e) => e.stopPropagation());
129
-
130
- if (v.body.kind === "answer") {
131
- const body = el("div", CLS.body);
132
- const answer = el("div", CLS.answer);
133
- answer.textContent = v.body.text;
134
- body.append(answer);
135
- if (v.body.evidence.length > 0) {
136
- const ev = el("div", CLS.evidence);
137
- ev.textContent = `${v.body.evidenceLabel}: ${v.body.evidence.join(" · ")}`;
138
- body.append(ev);
139
- }
140
- root.append(body);
141
- } else if (v.body.kind === "declined") {
142
- const declined = el("div", CLS.declined);
143
- declined.textContent = v.body.text;
144
- root.append(declined);
145
- }
127
+ for (const node of tree.children) root.append(renderNode(node, actions));
146
128
  this.append(root);
147
129
  }
148
130
  };
package/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export type { AnswerResult, ApiTarget };
13
13
  export type CxHookConfig = Partial<CxAgentConfig> & {
14
14
  /** 화면 언어. 한 번 넘기면 이후 문구는 전부 훅이 낸다. 미지정이면 "en". */
15
15
  locale?: string;
16
+ /** 중앙 설정 부트스트랩 (허브 client-config) — 기본 false. 명시 인자가 항상 이긴다. */
17
+ bootstrap?: boolean;
16
18
  /** 특정 문구만 덮어쓰기 — 나머지는 로케일 값 그대로 */
17
19
  messages?: Record<string, string>;
18
20
  };
@@ -30,6 +32,19 @@ export declare function declineText(reason: string, locale?: string): string;
30
32
  export interface CxHook {
31
33
  /** 주소·토큰·도메인이 다 있으면 true */
32
34
  enabled: boolean;
35
+ /** 부트스트랩 완료 신호 — bootstrap 미사용 시 즉시 resolve (실패도 resolve) */
36
+ ready: Promise<void>;
37
+ /** 중앙 발행 기능 플래그 — 부트스트랩 전/미발행이면 빈 객체 */
38
+ readonly flags: Record<string, unknown>;
39
+ /** 조사 요청 (E-8) — 허브 큐 적재만, 실행·검수는 공장. 활성 중복은 멱등 */
40
+ requestInvestigation(
41
+ externalId: string | number,
42
+ identity?: { userId?: string | number; subUserId?: string | number },
43
+ ): Promise<{ ok: boolean; status: string; error: string | null }>;
44
+ /** 조사 상태 (E-8) — 경량 메타만: none|queued|running|succeeded|failed */
45
+ investigationStatus(
46
+ externalId: string | number,
47
+ ): Promise<{ ok: boolean; status: string; jobId?: number; error: string | null }>;
33
48
  /** setup 에서 정해진 화면 언어 (정규화된 값) */
34
49
  locale: Locale;
35
50
  /** 이 훅의 문구 한 벌 — UI 라벨 포함 */
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,225 +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
- ...rest
56
- } = config;
57
- const messages = resolveMessages(locale, messageOverrides);
58
- const client =
59
- baseUrl && token && domain
60
- ? new CxAgentClient({
61
- baseUrl, token, domain, api,
62
- // 후킹 실패는 CS 업무와 무관 — 기록만 하고 화면을 막지 않는다.
63
- onError:
64
- onError ||
65
- ((err, ctx) => console.warn("[cx-agent-hook]", ctx.op, err.message)),
66
- ...rest,
67
- })
68
- : null;
69
-
70
- // 상담사가 어느 제안을 에디터에 넣었는지. **이 기록이 gold 쌍의 유일한 근거다**
71
- // — 발송된 최종 문구가 어느 answer_id 의 교정본인지 잇는 값이라, 여기가 비면
72
- // 후킹은 일어나도 "AI 초안 → 사람 최종본" 델타가 성립하지 않는다.
73
- // 고객사가 들고 다니게 하면 지우는 시점(문의 전환·발송 완료)까지 남의 코드에
74
- // 흩어진다. 라이브러리가 소유한다.
75
- let adoptedAnswerId = null;
76
-
77
- /** 채택된 제안에 대한 교정 후킹. 채택이 없었으면 조용히 무동작.
78
- *
79
- * `consume` 이 true 면 보낸 뒤 기록을 비운다 — 발송·폐기는 그 제안에 대한
80
- * 마지막 판단이라 두 번 세면 안 되고, 점수·수정은 발송 전에 여러 번 올 수 있다.
81
- */
82
- const emit = (send, consume) => {
83
- const id = adoptedAnswerId;
84
- if (consume) adoptedAnswerId = null;
85
- if (!client || !id) return;
86
- send(id);
87
- };
88
-
89
- return {
90
- /** 세 값이 다 있으면 true */
91
- enabled: Boolean(client),
92
- /** setup 에서 정해진 화면 언어 (정규화된 값) */
93
- locale: normalizeLocale(locale),
94
- /** 이 훅의 문구 한 벌 — 패널이 UI 라벨까지 여기서 가져간다 */
95
- messages,
96
- /** 사유 코드 → 이 훅의 언어로 된 평문 */
97
- declineText(reason) {
98
- return textOf(messages, reason);
99
- },
100
-
101
- /** 답변 제안 요청 — throw 하지 않음.
102
- * context: 문의에 붙는 고객 상황(기기·버전·융합 키) — 선택. */
103
- requestAnswer(inquiry, context) {
104
- if (!client || !inquiry) return Promise.resolve(NOT_CONFIGURED);
105
- return client.getAnswer(inquiry, context);
106
- },
107
-
108
- /** 초안 직주입 스트리밍 — 답변 에디터에 AI 초안을 직접 흘려 쓴다.
109
- *
110
- * 패널·채택 버튼 없는 흐름의 정본이다: 소비처는 에디터 접근자(getDraft·
111
- * setDraft)와 상태 표시(setStatus)만 배선하고, 나머지 제품 판단 —
112
- * 덮어쓰기 확인, 교정 재시작 처리, 실패 시 원복, 문구, **자동 채택 귀속**
113
- * (직주입 = 채택이므로 성공 시 noteAdopted 를 훅이 스스로 부른다) — 은
114
- * 전부 여기 있다. answerSent 의 consume 의미론은 그대로다.
115
- *
116
- * @param {object} opts
117
- * @param {string} opts.inquiry
118
- * @param {Record<string,string>} [opts.context]
119
- * @param {()=>string} [opts.getDraft] 현재 초안 (덮어쓰기 가드용)
120
- * @param {(text:string)=>void} opts.setDraft 초안 전체 치환 (누적 스냅샷)
121
- * @param {(text:string)=>void} [opts.setStatus] 한 줄 상태 ("" = 지움)
122
- * @param {()=>boolean} [opts.confirmOverwrite] 초안이 비어있지 않을 때 확인
123
- * @returns {{promise: Promise<object>, abort: ()=>void}}
124
- */
125
- composeDraft({ inquiry, context, getDraft, setDraft, setStatus,
126
- confirmOverwrite, customerName } = {}) {
127
- const status = (text) => { if (setStatus) setStatus(text || ""); };
128
- const noop = { promise: Promise.resolve(NOT_CONFIGURED), abort: () => {} };
129
- if (!client || !inquiry || typeof setDraft !== "function") {
130
- status(textOf(messages, "not_configured"));
131
- return noop;
132
- }
133
- const existing = typeof getDraft === "function"
134
- ? String(getDraft() || "") : "";
135
- if (existing.trim() && !(confirmOverwrite && confirmOverwrite())) {
136
- // 상담사가 쓰던 초안이 우선한다 — 조용히 덮지 않는다.
137
- return { promise: Promise.resolve({ ...NOT_CONFIGURED, declinedReason: "" }),
138
- abort: () => {} };
139
- }
140
- // 새 스트림 = 이전 채택 무효 (문의가 같아도 초안이 바뀐다)
141
- adoptedAnswerId = null;
142
-
143
- // 호칭 개인화 — 0.2.2: 이름을 context 로도 보낸다. 서버가 사설
144
- // 서빙(AI_CS_LLM_PRIVATE)이면 인사말을 처음부터 이름으로 굽고,
145
- // **저장은 하지 않는다**(로그에는 name_provided 마커만 — 서버 계약).
146
- // 아래 클라 치환은 안전망으로 유지: 구 서버·클라우드 폴백처럼 이름
147
- // 없이 "안녕하세요 고객님"으로 온 초안에만 작동하고, 서버가 이미
148
- // 이름을 넣었으면 패턴이 안 맞아 no-op 이다.
149
- const name = String(customerName || "").trim();
150
- const contextWithName = name
151
- ? { ...(context || {}), customerName: name }
152
- : context;
153
- const personalize = (text) =>
154
- name
155
- ? String(text).replace(
156
- /안녕하세요[,]?\s*고객님/,
157
- (m) => m.replace("고객님", `${name} 고객님`),
158
- )
159
- : text;
160
-
161
- const controller = new AbortController();
162
- let accumulated = "";
163
- let pending = null;
164
- const flushDraft = () => { pending = null; setDraft(personalize(accumulated)); };
165
- const queueDraft = () => {
166
- // trailing 스로틀 — 청크마다 리렌더하면 큰 화면이 버벅인다
167
- if (pending === null) pending = setTimeout(flushDraft, 80);
168
- };
169
- const clearPending = () => {
170
- if (pending !== null) { clearTimeout(pending); pending = null; }
171
- };
172
-
173
- status(messages.ui_requesting);
174
- setDraft("");
175
- const promise = client.getAnswerStream(inquiry, contextWithName, {
176
- signal: controller.signal,
177
- onDelta(text) { accumulated += text; queueDraft(); },
178
- onRestart() {
179
- // 계약 위반 교정 — 지금까지 보인 초안은 폐기본이다
180
- clearPending();
181
- accumulated = "";
182
- setDraft("");
183
- status(messages.ui_correcting);
184
- },
185
- onStage(phase) {
186
- if (phase === "generating") status(messages.ui_requesting);
187
- },
188
- }).then((result) => {
189
- clearPending();
190
- if (result.answered) {
191
- setDraft(personalize(result.answer)); // 정본 확정 (strip+호칭 반영)
192
- adoptedAnswerId = result.answerId ?? null;
193
- status("");
194
- } else {
195
- // 실패·거절 — 미완성 텍스트를 에디터에 남기지 않고 원래 초안 복원
196
- setDraft(existing);
197
- status(textOf(messages, result.declinedReason || "unknown"));
198
- }
199
- return result;
200
- });
201
- return {
202
- promise,
203
- abort: () => { clearPending(); controller.abort(); },
204
- };
205
- },
206
-
207
- /** 제안을 에디터에 넣었다 (패널이 부른다) */
208
- noteAdopted(answerId) {
209
- adoptedAnswerId = answerId || null;
210
- },
211
- /** 채택 기록 폐기 — 다른 문의로 옮겼을 때 */
212
- clearAdopted() {
213
- adoptedAnswerId = null;
214
- },
215
- /** 지금 채택된 제안이 있나 (표시·검증용) */
216
- get adoptedAnswerId() {
217
- return adoptedAnswerId;
218
- },
219
-
220
- /** 답변 발송됨 — 채택본이었다면 gold 쌍으로 후킹 (fire-and-forget).
221
- *
222
- * 채택 없이 상담사가 직접 쓴 답변이면 아무 일도 안 한다. 한 번 보내면
223
- * 기록을 비운다 — 같은 문의를 다시 발송해도 중복 교정이 쌓이지 않는다.
224
- */
225
- answerSent(finalText, agent) {
226
- emit((id) => client.sent(id, finalText, agent), true);
227
- },
228
- /** 제안 품질 점수 1~5 (발송 전에 여러 번 올 수 있어 기록을 비우지 않는다) */
229
- scored(score, agent) {
230
- emit((id) => client.scored(id, score, agent), false);
231
- },
232
- /** 제안을 고쳐 썼다 — 초안↔최종본 델타 (발송 전 중간 저장 가능) */
233
- edited(finalText, agent) {
234
- emit((id) => client.edited(id, finalText, agent), false);
235
- },
236
- /** 제안을 버렸다 + 이유. 그 제안에 대한 마지막 판단이라 기록을 비운다. */
237
- discarded(agent, note) {
238
- emit((id) => client.discarded(id, agent, note), true);
239
- },
240
-
241
- /** 문의+상담사 최종답변 쌍 적재 (fire-and-forget, external_id 멱등).
242
- *
243
- * AI 제안을 안 써도 이것만 붙으면 광물이 쌓인다 — 인입의 유일한 통로.
244
- */
245
- inquirySent({ externalId, inquiry, reply, agent, meta } = {}) {
246
- if (!client || !externalId || !inquiry) return;
247
- client.logInquiry({ externalId, inquiry, reply, agent, meta });
248
- },
249
- };
250
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcg-labs/cx-agent-hook",
3
- "version": "0.2.2",
3
+ "version": "0.3.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