@fcg-labs/cx-agent-hook 0.1.1 → 0.2.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/astro.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare function setupCxAiSuggest(
11
11
  options?: {
12
12
  hook?: CxHook;
13
13
  inquiry?: string;
14
+ context?: Record<string, string> | null;
14
15
  onAdopt?: (text: string, answerId: number | string | null) => void;
15
16
  },
16
17
  ): () => void;
package/astro.js CHANGED
@@ -43,11 +43,14 @@ export { aiSuggestView } from "./view.js";
43
43
  *
44
44
  * @returns {() => void} 구독 해제
45
45
  */
46
- export function setupCxAiSuggest(element, { hook, inquiry = "", onAdopt } = {}) {
46
+ export function setupCxAiSuggest(
47
+ element, { hook, inquiry = "", context = null, onAdopt } = {},
48
+ ) {
47
49
  defineCxAiSuggest();
48
50
  if (!element) return () => {};
49
51
  element.hook = hook;
50
52
  element.inquiry = inquiry;
53
+ element.context = context;
51
54
  if (!onAdopt) return () => {};
52
55
  const handler = (event) => onAdopt(event.detail.text, event.detail.answerId);
53
56
  element.addEventListener("cx-adopt", handler);
package/client.d.ts CHANGED
@@ -54,9 +54,31 @@ export interface FeedbackInput {
54
54
  note?: string;
55
55
  }
56
56
 
57
+ export interface AnswerStreamHandlers {
58
+ /** 텍스트 조각 — 누적(append) */
59
+ onDelta?(text: string): void;
60
+ /** 교정 재생성 시작 — 누적 전부 폐기 */
61
+ onRestart?(reasons: string[]): void;
62
+ onStage?(phase: string): void;
63
+ onFinal?(result: AnswerResult): void;
64
+ /** 외부 중단 (문의 전환 등) */
65
+ signal?: AbortSignal;
66
+ idleTimeoutMs?: number;
67
+ overallTimeoutMs?: number;
68
+ }
69
+
57
70
  export declare class CxAgentClient {
58
71
  constructor(config: CxAgentConfig);
59
- getAnswer(inquiry: string): Promise<AnswerResult>;
72
+ getAnswer(
73
+ inquiry: string,
74
+ context?: Record<string, string>,
75
+ ): Promise<AnswerResult>;
76
+ /** SSE 스트리밍 — 델타 콜백 + getAnswer 동형 결과. 404/405 는 비스트림 폴백. */
77
+ getAnswerStream(
78
+ inquiry: string,
79
+ context?: Record<string, string>,
80
+ handlers?: AnswerStreamHandlers,
81
+ ): Promise<AnswerResult>;
60
82
  sendFeedback(fb: FeedbackInput): Promise<FeedbackResult>;
61
83
  sent(answerId: number | string, finalText: string, agent?: string): Promise<FeedbackResult>;
62
84
  scored(answerId: number | string, score: number, agent?: string): Promise<FeedbackResult>;
package/client.js CHANGED
@@ -21,6 +21,7 @@ const FEEDBACK_ACTIONS = new Set(["scored", "edited", "sent", "discarded"]);
21
21
  const PATHS = {
22
22
  platform: {
23
23
  answer: (d) => `/api/domains/${d}/answer`,
24
+ answerStream: (d) => `/api/domains/${d}/answer/stream`,
24
25
  feedback: (d) => `/api/domains/${d}/feedback`,
25
26
  },
26
27
  hub: {
@@ -31,16 +32,54 @@ const PATHS = {
31
32
  // 허브에 라우트가 생긴 뒤에도 SDK 가 그대로여서 hub 대상 getAnswer 가
32
33
  // HTTP 를 타보지도 못하고 unsupported_api 로 단락됐다 (2026-07-31 감사).
33
34
  answer: (d) => `/v1/domains/${d}/answer`,
35
+ answerStream: (d) => `/v1/domains/${d}/answer/stream`,
34
36
  feedback: (d) => `/v1/domains/${d}/feedback`,
35
37
  ingress: (d) => `/v1/domains/${d}/ingress`,
36
38
  },
37
39
  // platform 은 인그레스 API 미제공 (공장 큐레이션 파이프라인이 담당)
38
40
  };
39
41
 
42
+ /**
43
+ * SSE 블록 1개 파싱 — "event: 이름" + "data: JSON 한 줄" 규약.
44
+ * 규약 밖 블록(주석·미지 이벤트·깨진 JSON)은 null — 스트림을 죽이지 않는다.
45
+ */
46
+ function parseSSEBlock(block) {
47
+ let name = "";
48
+ let data = "";
49
+ for (const line of block.split("\n")) {
50
+ if (line.startsWith("event: ")) name = line.slice(7).trim();
51
+ else if (line.startsWith("data: ")) data = line.slice(6);
52
+ }
53
+ if (!name || !data) return null;
54
+ try {
55
+ return { name, data: JSON.parse(data) };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
40
61
  function sleep(ms) {
41
62
  return new Promise((resolve) => setTimeout(resolve, ms));
42
63
  }
43
64
 
65
+ /**
66
+ * 고객 상황 맵 정규화 — 값 문자열 강제, 빈 값 제거. 실을 것이 없으면 null.
67
+ *
68
+ * 어휘 판단(어떤 키가 프롬프트에 실리나)은 SDK 의 몫이 아니다 — 서버의
69
+ * serving_context 가 정본이고, 여기는 "문자열 맵" 이라는 모양만 보장한다.
70
+ */
71
+ function normalizeContext(context) {
72
+ if (!context || typeof context !== "object") return null;
73
+ const out = {};
74
+ for (const [key, value] of Object.entries(context)) {
75
+ if (value === null || value === undefined) continue;
76
+ const v = String(value).trim();
77
+ if (!v) continue;
78
+ out[String(key)] = v;
79
+ }
80
+ return Object.keys(out).length ? out : null;
81
+ }
82
+
44
83
  export class CxAgentClient {
45
84
  /**
46
85
  * @param {object} config
@@ -141,10 +180,13 @@ export class CxAgentClient {
141
180
  /**
142
181
  * 서빙 답변 요청. throw 하지 않는다 — ok 로 판별.
143
182
  * @param {string} inquiry 고객 문의 원문
183
+ * @param {Record<string,string>} [context] 문의에 붙는 고객 상황 —
184
+ * 기기·앱 버전·문의 유형 같은 접지 신호와 융합 키(user_id 등).
185
+ * 값은 문자열로 강제되고, 비면 키 자체를 싣지 않는다(역호환).
144
186
  * @returns {Promise<{ok:boolean, answered:boolean, answer:string,
145
187
  * answerId:number|null, evidence:Array, declinedReason:string, raw:object}>}
146
188
  */
147
- async getAnswer(inquiry) {
189
+ async getAnswer(inquiry, context) {
148
190
  const target = this._target("answer");
149
191
  if (!target.path) {
150
192
  // throw 하지 않는다 — 이 SDK 의 원칙은 "CS팀 업무를 절대 막지 않는다".
@@ -159,8 +201,11 @@ export class CxAgentClient {
159
201
  };
160
202
  }
161
203
  try {
204
+ const body = { inquiry };
205
+ const ctx = normalizeContext(context);
206
+ if (ctx) body.context = ctx;
162
207
  const { status, data } = await this._post(
163
- target.path(this.domain), { inquiry }, target,
208
+ target.path(this.domain), body, target,
164
209
  );
165
210
  if (status !== 200) {
166
211
  // 서버가 준 사유를 그대로 옮긴다. `http_503` 으로 뭉개면 "아직 안 켬"
@@ -196,6 +241,132 @@ export class CxAgentClient {
196
241
  }
197
242
  }
198
243
 
244
+ /**
245
+ * 서빙 답변 스트리밍 — 델타를 콜백으로 흘리고 getAnswer 와 동형의 결과로
246
+ * resolve 한다. throw 하지 않는다.
247
+ *
248
+ * 타임아웃 계단(정본): 서버 LLM 60s×2 < 허브 180s < 여기 overall 190s.
249
+ * idle 90s 는 침묵 구간(콜드 검색+첫 토큰, 교정 재시작 후 첫 토큰)을 덮는다 —
250
+ * 더 짧으면 정상 생성을 오탐으로 자른다 (30s 시절 사고의 재판 금지).
251
+ *
252
+ * @param {string} inquiry
253
+ * @param {Record<string,string>} [context]
254
+ * @param {object} [handlers]
255
+ * @param {(text:string)=>void} [handlers.onDelta] 텍스트 조각 (append)
256
+ * @param {(reasons:string[])=>void} [handlers.onRestart] 교정 재생성 시작 —
257
+ * 지금까지의 누적을 전부 버릴 것
258
+ * @param {(phase:string)=>void} [handlers.onStage] retrieving|generating|correcting
259
+ * @param {(result:object)=>void} [handlers.onFinal]
260
+ * @param {AbortSignal} [handlers.signal] 외부 중단 (문의 전환 등)
261
+ * @param {number} [handlers.idleTimeoutMs=90000]
262
+ * @param {number} [handlers.overallTimeoutMs=190000]
263
+ */
264
+ async getAnswerStream(inquiry, context, {
265
+ onDelta, onRestart, onStage, onFinal, signal,
266
+ idleTimeoutMs = 90000, overallTimeoutMs = 190000,
267
+ } = {}) {
268
+ const fail = (reason, raw = {}) => ({
269
+ ok: false, answered: false, answer: "", answerId: null,
270
+ evidence: [], declinedReason: reason, raw,
271
+ });
272
+ const target = this._target("answer");
273
+ const streamPath = PATHS[target.api] && PATHS[target.api].answerStream;
274
+ if (!streamPath) {
275
+ this.onError(
276
+ new Error("답변 생성 대상이 설정되지 않았습니다 (answer.baseUrl 확인)"),
277
+ { op: "getAnswerStream" },
278
+ );
279
+ return fail("unsupported_api");
280
+ }
281
+ const body = { inquiry };
282
+ const ctx = normalizeContext(context);
283
+ if (ctx) body.context = ctx;
284
+
285
+ const controller = new AbortController();
286
+ const abort = () => controller.abort();
287
+ if (signal) {
288
+ if (signal.aborted) return fail("network_error");
289
+ signal.addEventListener("abort", abort, { once: true });
290
+ }
291
+ const overallTimer = setTimeout(abort, overallTimeoutMs);
292
+ let idleTimer = setTimeout(abort, idleTimeoutMs);
293
+ const bumpIdle = () => {
294
+ clearTimeout(idleTimer);
295
+ idleTimer = setTimeout(abort, idleTimeoutMs);
296
+ };
297
+ try {
298
+ const res = await this.fetchImpl(target.baseUrl + streamPath(this.domain), {
299
+ method: "POST",
300
+ headers: {
301
+ "Authorization": `Bearer ${target.token}`,
302
+ "Content-Type": "application/json",
303
+ },
304
+ body: JSON.stringify(body),
305
+ signal: controller.signal,
306
+ });
307
+ if (res.status === 404 || res.status === 405) {
308
+ // 구 서버(스트림 라우트 이전) 혼재 배포 — 비스트림으로 폴백.
309
+ // 델타 없이 완성본이 한 번에 오지만 기능은 산다 (배포 순서 안전판).
310
+ return await this.getAnswer(inquiry, context);
311
+ }
312
+ if (res.status !== 200) {
313
+ const data = await res.json().catch(() => ({}));
314
+ return fail((data && data.error) || `http_${res.status}`, data);
315
+ }
316
+ const reader = res.body.getReader();
317
+ // {stream:true} 필수 — 한글 3바이트 문자가 청크 경계에서 잘리면
318
+ // 이것 없이는 U+FFFD 로 깨진다 (한국어 스트림의 최다 빈도 버그).
319
+ const decoder = new TextDecoder("utf-8");
320
+ let buffer = "";
321
+ let final = null;
322
+ for (;;) {
323
+ const { done, value } = await reader.read();
324
+ if (done) break;
325
+ bumpIdle();
326
+ buffer += decoder.decode(value, { stream: true });
327
+ let sep;
328
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
329
+ const event = parseSSEBlock(buffer.slice(0, sep));
330
+ buffer = buffer.slice(sep + 2);
331
+ if (!event) continue;
332
+ if (event.name === "delta") {
333
+ if (onDelta) onDelta(String(event.data.text || ""));
334
+ } else if (event.name === "restart") {
335
+ if (onRestart) onRestart(event.data.reasons || []);
336
+ } else if (event.name === "stage") {
337
+ if (onStage) onStage(String(event.data.phase || ""));
338
+ } else if (event.name === "final") {
339
+ final = event.data;
340
+ }
341
+ }
342
+ }
343
+ if (!final) {
344
+ // final 없이 끊김 = 절단 (허브 상한·네트워크) — 부분 텍스트는 무효다.
345
+ this.onError(new Error("스트림이 final 없이 종료됐습니다"),
346
+ { op: "getAnswerStream" });
347
+ return fail("network_error");
348
+ }
349
+ const result = {
350
+ ok: true,
351
+ answered: Boolean(final.answered),
352
+ answer: final.answer || "",
353
+ answerId: final.answer_id ?? null,
354
+ evidence: final.evidence || [],
355
+ declinedReason: final.declined_reason || "",
356
+ raw: final,
357
+ };
358
+ if (onFinal) onFinal(result);
359
+ return result;
360
+ } catch (err) {
361
+ this.onError(err, { op: "getAnswerStream" });
362
+ return fail("network_error");
363
+ } finally {
364
+ clearTimeout(overallTimer);
365
+ clearTimeout(idleTimer);
366
+ if (signal) signal.removeEventListener("abort", abort);
367
+ }
368
+ }
369
+
199
370
  /**
200
371
  * 교정 후킹 — CS팀 업무를 막지 않는다: throw 없이 재시도 후 결과 보고.
201
372
  * @param {object} fb
package/element.d.ts CHANGED
@@ -9,6 +9,8 @@ export declare const TAG: "cx-ai-suggest";
9
9
  export interface CxAiSuggestElement extends HTMLElement {
10
10
  hook: CxHook | null;
11
11
  inquiry: string;
12
+ /** 고객 상황 — 문의 리셋과 무관하게 최신값만 쓴다 */
13
+ context: Record<string, string> | null;
12
14
  }
13
15
 
14
16
  /** 커스텀 엘리먼트 등록. 두 번 불러도 안전하고, 브라우저가 아니면 무동작(false). */
package/element.js CHANGED
@@ -45,6 +45,7 @@ function createClass() {
45
45
  return class CxAiSuggestElement extends HTMLElement {
46
46
  #hook = null;
47
47
  #inquiry = "";
48
+ #context = null;
48
49
  #state = "idle";
49
50
  #result = null;
50
51
 
@@ -52,6 +53,12 @@ function createClass() {
52
53
  get hook() { return this.#hook; }
53
54
  set hook(value) { this.#hook = value; this.#render(); }
54
55
 
56
+ /** 고객 상황 — 기기·버전·융합 키. 문의 리셋과 무관하게 최신값만 쓴다. */
57
+ get context() { return this.#context; }
58
+ set context(value) {
59
+ this.#context = value && typeof value === "object" ? value : null;
60
+ }
61
+
55
62
  /** 대상 문의 원문 */
56
63
  get inquiry() { return this.#inquiry; }
57
64
  set inquiry(value) {
@@ -73,7 +80,9 @@ function createClass() {
73
80
  if (!this.#inquiry || this.#state === "loading") return;
74
81
  this.#state = "loading";
75
82
  this.#render();
76
- this.#result = await this.#hook.requestAnswer(this.#inquiry);
83
+ this.#result = await this.#hook.requestAnswer(
84
+ this.#inquiry, this.#context || undefined,
85
+ );
77
86
  this.#state = "done";
78
87
  this.#render();
79
88
  }
package/index.d.ts CHANGED
@@ -37,7 +37,21 @@ export interface CxHook {
37
37
  /** 사유 코드 → 이 훅의 언어로 된 평문 */
38
38
  declineText(reason: string): string;
39
39
 
40
- requestAnswer(inquiry: string): Promise<AnswerResult>;
40
+ requestAnswer(
41
+ inquiry: string,
42
+ context?: Record<string, string>,
43
+ ): Promise<AnswerResult>;
44
+
45
+ /** 초안 직주입 스트리밍 — 에디터 접근자만 배선하면 나머지 판단은 훅 소유.
46
+ * 성공 시 자동 채택(noteAdopted) — answerSent 귀속이 그대로 성립한다. */
47
+ composeDraft(opts: {
48
+ inquiry: string;
49
+ context?: Record<string, string>;
50
+ getDraft?: () => string;
51
+ setDraft: (text: string) => void;
52
+ setStatus?: (text: string) => void;
53
+ confirmOverwrite?: () => boolean;
54
+ }): { promise: Promise<AnswerResult>; abort: () => void };
41
55
 
42
56
  /** 제안을 에디터에 넣었다 (패널이 부른다) */
43
57
  noteAdopted(answerId: number | string | null): void;
package/index.js CHANGED
@@ -98,10 +98,92 @@ export function createCxHook(config = {}) {
98
98
  return textOf(messages, reason);
99
99
  },
100
100
 
101
- /** 답변 제안 요청 — throw 하지 않음 */
102
- requestAnswer(inquiry) {
101
+ /** 답변 제안 요청 — throw 하지 않음.
102
+ * context: 문의에 붙는 고객 상황(기기·버전·융합 키) — 선택. */
103
+ requestAnswer(inquiry, context) {
103
104
  if (!client || !inquiry) return Promise.resolve(NOT_CONFIGURED);
104
- return client.getAnswer(inquiry);
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 } = {}) {
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
+ const controller = new AbortController();
144
+ let accumulated = "";
145
+ let pending = null;
146
+ const flushDraft = () => { pending = null; setDraft(accumulated); };
147
+ const queueDraft = () => {
148
+ // trailing 스로틀 — 청크마다 리렌더하면 큰 화면이 버벅인다
149
+ if (pending === null) pending = setTimeout(flushDraft, 80);
150
+ };
151
+ const clearPending = () => {
152
+ if (pending !== null) { clearTimeout(pending); pending = null; }
153
+ };
154
+
155
+ status(messages.ui_requesting);
156
+ setDraft("");
157
+ const promise = client.getAnswerStream(inquiry, context, {
158
+ signal: controller.signal,
159
+ onDelta(text) { accumulated += text; queueDraft(); },
160
+ onRestart() {
161
+ // 계약 위반 교정 — 지금까지 보인 초안은 폐기본이다
162
+ clearPending();
163
+ accumulated = "";
164
+ setDraft("");
165
+ status(messages.ui_correcting);
166
+ },
167
+ onStage(phase) {
168
+ if (phase === "generating") status(messages.ui_requesting);
169
+ },
170
+ }).then((result) => {
171
+ clearPending();
172
+ if (result.answered) {
173
+ setDraft(result.answer); // 정본으로 확정 (strip 반영)
174
+ adoptedAnswerId = result.answerId ?? null;
175
+ status("");
176
+ } else {
177
+ // 실패·거절 — 미완성 텍스트를 에디터에 남기지 않고 원래 초안 복원
178
+ setDraft(existing);
179
+ status(textOf(messages, result.declinedReason || "unknown"));
180
+ }
181
+ return result;
182
+ });
183
+ return {
184
+ promise,
185
+ abort: () => { clearPending(); controller.abort(); },
186
+ };
105
187
  },
106
188
 
107
189
  /** 제안을 에디터에 넣었다 (패널이 부른다) */
package/locales.js CHANGED
@@ -30,6 +30,8 @@ const en = {
30
30
  ui_requesting: "Generating…",
31
31
  ui_adopt: "Insert into editor",
32
32
  ui_evidence: "Sources",
33
+ ui_correcting: "Fixing phrasing — rewriting…",
34
+ ui_overwrite_confirm: "Replace your current draft with the AI draft?",
33
35
  // 거절·오류 사유
34
36
  not_configured: "AI reply suggestions are not connected yet.",
35
37
  unsupported_api: "AI reply suggestions are not connected yet.",
@@ -54,6 +56,8 @@ const ko = {
54
56
  ui_requesting: "제안 생성 중...",
55
57
  ui_adopt: "에디터에 넣기",
56
58
  ui_evidence: "근거",
59
+ ui_correcting: "표현 교정 중 — 다시 쓰는 중...",
60
+ ui_overwrite_confirm: "작성 중인 답변을 지우고 AI 초안으로 바꿀까요?",
57
61
  not_configured: "AI 답변 제안이 아직 연결되지 않았습니다.",
58
62
  unsupported_api: "AI 답변 제안이 아직 연결되지 않았습니다.",
59
63
  answer_disabled: "AI 답변 제안은 아직 켜지지 않았습니다. 문의·답변 수집만 진행 중입니다.",
@@ -73,6 +77,8 @@ const ja = {
73
77
  ui_requesting: "生成中...",
74
78
  ui_adopt: "エディタに挿入",
75
79
  ui_evidence: "根拠",
80
+ ui_correcting: "表現を修正中 — 書き直しています…",
81
+ ui_overwrite_confirm: "作成中の回答を消してAI下書きに置き換えますか?",
76
82
  not_configured: "AI 返信案はまだ接続されていません。",
77
83
  unsupported_api: "AI 返信案はまだ接続されていません。",
78
84
  answer_disabled: "AI 返信案はまだ有効になっていません。問い合わせの収集のみ実行中です。",
@@ -92,6 +98,8 @@ const zhTW = {
92
98
  ui_requesting: "產生中...",
93
99
  ui_adopt: "插入編輯器",
94
100
  ui_evidence: "依據",
101
+ ui_correcting: "正在修正表述 — 重新撰寫中…",
102
+ ui_overwrite_confirm: "要清除目前草稿並以 AI 草稿取代嗎?",
95
103
  not_configured: "AI 回覆建議尚未連接。",
96
104
  unsupported_api: "AI 回覆建議尚未連接。",
97
105
  answer_disabled: "AI 回覆建議尚未啟用,目前僅進行問題收集。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcg-labs/cx-agent-hook",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/react.d.ts CHANGED
@@ -7,6 +7,8 @@ export type { AiSuggestView } from "./view.js";
7
7
  export interface AiSuggestPanelProps {
8
8
  hook: CxHook;
9
9
  inquiry: string | undefined | null;
10
+ /** 고객 상황 — 요청 시점 최신값 사용 (리셋 deps 아님) */
11
+ context?: Record<string, string> | null;
10
12
  onAdopt?: (text: string) => void;
11
13
  /** 바깥 배치용 (레이아웃만 — 색·간격은 styles.css 변수로) */
12
14
  className?: string;
package/react.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * 단계가 없고(의존성 0 이 설계 성질), JSX 를 그대로 배포하면 소비처 번들러가
18
18
  * node_modules 를 변환해 주기를 기대해야 한다. createElement 로 직접 쓴다.
19
19
  */
20
- import { createElement as h, useCallback, useEffect, useState } from "react";
20
+ import { createElement as h, useCallback, useEffect, useRef, useState } from "react";
21
21
 
22
22
  import { aiSuggestView, CLS } from "./view.js";
23
23
 
@@ -28,10 +28,13 @@ export { aiSuggestView };
28
28
  * @param {object} props
29
29
  * @param {object} props.hook createCxHook() 결과
30
30
  * @param {string} props.inquiry 대상 문의 원문
31
+ * @param {Record<string,string>} [props.context] 고객 상황 — 기기·버전·융합 키.
32
+ * 요청 시점의 최신 값을 쓴다. **리셋 deps 에 넣지 않는다** — 인라인 객체를
33
+ * 넘기면 리렌더마다 제안·채택 상태가 초기화된다.
31
34
  * @param {(text: string) => void} props.onAdopt "에디터에 넣기"
32
35
  * @param {string} [props.className] 바깥 배치용 (레이아웃만)
33
36
  */
34
- export function AiSuggestPanel({ hook, inquiry, onAdopt, className }) {
37
+ export function AiSuggestPanel({ hook, inquiry, context, onAdopt, className }) {
35
38
  const [state, setState] = useState("idle");
36
39
  const [result, setResult] = useState(null);
37
40
 
@@ -43,10 +46,15 @@ export function AiSuggestPanel({ hook, inquiry, onAdopt, className }) {
43
46
  hook.clearAdopted();
44
47
  }, [inquiry, hook]);
45
48
 
49
+ // context 는 ref 로 최신값만 읽는다 — deps 에 넣으면 인라인 객체가
50
+ // 리렌더마다 새 참조가 되어 요청 콜백이 계속 재생성된다.
51
+ const contextRef = useRef(context);
52
+ contextRef.current = context;
53
+
46
54
  const request = useCallback(async () => {
47
55
  if (!inquiry || state === "loading") return;
48
56
  setState("loading");
49
- setResult(await hook.requestAnswer(inquiry));
57
+ setResult(await hook.requestAnswer(inquiry, contextRef.current));
50
58
  setState("done");
51
59
  }, [hook, inquiry, state]);
52
60
 
package/vue.d.ts CHANGED
@@ -7,6 +7,8 @@ export type { AiSuggestView } from "./view.js";
7
7
  export interface AiSuggestPanelProps {
8
8
  hook: CxHook;
9
9
  inquiry?: string;
10
+ /** 고객 상황 — 기기·버전·융합 키 */
11
+ context?: Record<string, string> | null;
10
12
  /** 바깥 배치용 (레이아웃만 — 색·간격은 styles.css 변수로) */
11
13
  className?: string;
12
14
  }
package/vue.js CHANGED
@@ -30,6 +30,8 @@ export const AiSuggestPanel = defineComponent({
30
30
  props: {
31
31
  hook: { type: Object, required: true },
32
32
  inquiry: { type: String, default: "" },
33
+ /** 고객 상황 — 기기·버전·융합 키 (요청 시점 최신값 사용) */
34
+ context: { type: Object, default: null },
33
35
  /** 바깥 배치용 (레이아웃만 — 색·간격은 styles.css 변수로) */
34
36
  className: { type: String, default: "" },
35
37
  },
@@ -53,7 +55,9 @@ export const AiSuggestPanel = defineComponent({
53
55
  const request = async () => {
54
56
  if (!props.inquiry || state.value === "loading") return;
55
57
  state.value = "loading";
56
- result.value = await props.hook.requestAnswer(props.inquiry);
58
+ result.value = await props.hook.requestAnswer(
59
+ props.inquiry, props.context || undefined,
60
+ );
57
61
  state.value = "done";
58
62
  };
59
63