@fcg-labs/cx-agent-hook 0.1.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/LICENSE +96 -0
- package/README.md +175 -0
- package/angular.d.ts +5 -0
- package/angular.js +38 -0
- package/astro.d.ts +16 -0
- package/astro.js +55 -0
- package/client.d.ts +84 -0
- package/client.js +359 -0
- package/element.d.ts +22 -0
- package/element.js +148 -0
- package/index.d.ts +68 -0
- package/index.js +150 -0
- package/locales.d.ts +9 -0
- package/locales.js +136 -0
- package/next.d.ts +3 -0
- package/next.js +20 -0
- package/package.json +102 -0
- package/react.d.ts +18 -0
- package/react.js +110 -0
- package/styles.css +40 -0
- package/view.d.ts +27 -0
- package/view.js +60 -0
- package/vue.d.ts +16 -0
- package/vue.js +119 -0
package/index.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fcg-labs/cx-agent-hook — FCG CX Agent 후킹 SDK (공개 표면).
|
|
3
|
+
*
|
|
4
|
+
* 소비처(고객사 관리자 화면)가 닿는 것은 이 파일과 `./react` 뿐이다.
|
|
5
|
+
* 전송 계층(`CxAgentClient`)은 `client.js` 에 있고 **밖으로 내보내지 않는다** —
|
|
6
|
+
* 열어 두면 새 능력을 붙일 때 자연히 그리로 내려가고, 그 순간 `answer_id`
|
|
7
|
+
* 수명 관리 같은 제품 지식이 다시 고객사 코드로 흩어진다. 능력이 모자라면
|
|
8
|
+
* 저수준을 노출하는 게 아니라 **여기 표면을 채운다.**
|
|
9
|
+
*
|
|
10
|
+
* ① 답변 제안 수신 requestAnswer
|
|
11
|
+
* ② 상담사 판단 후킹 answerSent · scored · edited · discarded
|
|
12
|
+
* ③ 문의·답변 쌍 적재 inquirySent ★ 이게 빠지면 아무것도 안 쌓인다
|
|
13
|
+
*
|
|
14
|
+
* 원칙: 의존성 0 (내장 fetch), 어떤 메서드도 throw 하지 않는다.
|
|
15
|
+
*/
|
|
16
|
+
import { CxAgentClient } from "./client.js";
|
|
17
|
+
import { LOCALES, MESSAGES, normalizeLocale, resolveMessages } from "./locales.js";
|
|
18
|
+
|
|
19
|
+
export { LOCALES, MESSAGES, normalizeLocale };
|
|
20
|
+
|
|
21
|
+
/** 사유 코드 → 상담사가 읽을 평문.
|
|
22
|
+
*
|
|
23
|
+
* 보통은 `hook.declineText` 를 쓴다 — setup 에서 정한 로케일이 이미 적용돼 있다.
|
|
24
|
+
* 이 함수는 훅 없이 문구만 필요할 때(테스트·미리보기)의 출구다.
|
|
25
|
+
*/
|
|
26
|
+
export function declineText(reason, locale) {
|
|
27
|
+
return textOf(resolveMessages(locale), reason);
|
|
28
|
+
}
|
|
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
|
+
requestAnswer(inquiry) {
|
|
103
|
+
if (!client || !inquiry) return Promise.resolve(NOT_CONFIGURED);
|
|
104
|
+
return client.getAnswer(inquiry);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/** 제안을 에디터에 넣었다 (패널이 부른다) */
|
|
108
|
+
noteAdopted(answerId) {
|
|
109
|
+
adoptedAnswerId = answerId || null;
|
|
110
|
+
},
|
|
111
|
+
/** 채택 기록 폐기 — 다른 문의로 옮겼을 때 */
|
|
112
|
+
clearAdopted() {
|
|
113
|
+
adoptedAnswerId = null;
|
|
114
|
+
},
|
|
115
|
+
/** 지금 채택된 제안이 있나 (표시·검증용) */
|
|
116
|
+
get adoptedAnswerId() {
|
|
117
|
+
return adoptedAnswerId;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/** 답변 발송됨 — 채택본이었다면 gold 쌍으로 후킹 (fire-and-forget).
|
|
121
|
+
*
|
|
122
|
+
* 채택 없이 상담사가 직접 쓴 답변이면 아무 일도 안 한다. 한 번 보내면
|
|
123
|
+
* 기록을 비운다 — 같은 문의를 다시 발송해도 중복 교정이 쌓이지 않는다.
|
|
124
|
+
*/
|
|
125
|
+
answerSent(finalText, agent) {
|
|
126
|
+
emit((id) => client.sent(id, finalText, agent), true);
|
|
127
|
+
},
|
|
128
|
+
/** 제안 품질 점수 1~5 (발송 전에 여러 번 올 수 있어 기록을 비우지 않는다) */
|
|
129
|
+
scored(score, agent) {
|
|
130
|
+
emit((id) => client.scored(id, score, agent), false);
|
|
131
|
+
},
|
|
132
|
+
/** 제안을 고쳐 썼다 — 초안↔최종본 델타 (발송 전 중간 저장 가능) */
|
|
133
|
+
edited(finalText, agent) {
|
|
134
|
+
emit((id) => client.edited(id, finalText, agent), false);
|
|
135
|
+
},
|
|
136
|
+
/** 제안을 버렸다 + 이유. 그 제안에 대한 마지막 판단이라 기록을 비운다. */
|
|
137
|
+
discarded(agent, note) {
|
|
138
|
+
emit((id) => client.discarded(id, agent, note), true);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/** 문의+상담사 최종답변 쌍 적재 (fire-and-forget, external_id 멱등).
|
|
142
|
+
*
|
|
143
|
+
* AI 제안을 안 써도 이것만 붙으면 광물이 쌓인다 — 인입의 유일한 통로.
|
|
144
|
+
*/
|
|
145
|
+
inquirySent({ externalId, inquiry, reply, agent, meta } = {}) {
|
|
146
|
+
if (!client || !externalId || !inquiry) return;
|
|
147
|
+
client.logInquiry({ externalId, inquiry, reply, agent, meta });
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
package/locales.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Locale = "ko" | "en" | "ja" | "zh-TW";
|
|
2
|
+
export declare const LOCALES: Locale[];
|
|
3
|
+
export declare const DEFAULT_LOCALE: Locale;
|
|
4
|
+
export declare const MESSAGES: Record<Locale, Record<string, string>>;
|
|
5
|
+
export declare function normalizeLocale(locale: unknown): Locale;
|
|
6
|
+
/** 로케일 문구 + 덮어쓴 값. 빠진 키는 기본 로케일로 메운다. */
|
|
7
|
+
export declare function resolveMessages(
|
|
8
|
+
locale?: string, overrides?: Record<string, string>,
|
|
9
|
+
): Record<string, string>;
|
package/locales.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 상담사에게 보이는 문구 — 로케일별 한 벌.
|
|
3
|
+
*
|
|
4
|
+
* **왜 라이브러리가 갖나.** 어떤 사유가 존재하고 각각이 무슨 뜻인지는 제품
|
|
5
|
+
* 지식이다. 고객사마다 따로 쓰면 같은 상태를 다르게 안내하게 되고, 서버가
|
|
6
|
+
* 사유를 하나 늘릴 때마다 고객사 저장소를 전부 고쳐야 한다.
|
|
7
|
+
*
|
|
8
|
+
* **왜 무거운 i18n 이 아닌가.** 문구가 20개 남짓이고 런타임에 언어를 바꿀 일이
|
|
9
|
+
* 없다(관리자 화면은 한 언어로 뜬다). 라이브러리를 하나 더 물리는 대신 평범한
|
|
10
|
+
* 객체로 둔다 — 소비처 번들에 얹히는 것도 이게 가장 작다.
|
|
11
|
+
*
|
|
12
|
+
* **모르는 로케일은 조용히 영어로 떨어진다.** 화면에 기계 코드가 나오는 것보다
|
|
13
|
+
* 낫고, 번역이 빠진 항목도 같은 규칙으로 메운다(`resolveMessages`).
|
|
14
|
+
*
|
|
15
|
+
* 사유 코드의 출처(전부 서버가 준다):
|
|
16
|
+
* 허브 answer_disabled(답변 미담당) · answer_unavailable(업스트림 장애)
|
|
17
|
+
* 공장 serving_disabled · gate_blocked · no_evidence · contract_violation
|
|
18
|
+
* SDK not_configured · unsupported_api · network_error
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 지원 로케일. 값은 BCP 47 접두와 맞춘다 (ko-KR → ko). */
|
|
22
|
+
export const LOCALES = ["ko", "en", "ja", "zh-TW"];
|
|
23
|
+
|
|
24
|
+
/** 폴백 기준 — 여기 있는 키가 전체 목록이다. */
|
|
25
|
+
export const DEFAULT_LOCALE = "en";
|
|
26
|
+
|
|
27
|
+
const en = {
|
|
28
|
+
// 패널 UI
|
|
29
|
+
ui_request: "Suggest a reply",
|
|
30
|
+
ui_requesting: "Generating…",
|
|
31
|
+
ui_adopt: "Insert into editor",
|
|
32
|
+
ui_evidence: "Sources",
|
|
33
|
+
// 거절·오류 사유
|
|
34
|
+
not_configured: "AI reply suggestions are not connected yet.",
|
|
35
|
+
unsupported_api: "AI reply suggestions are not connected yet.",
|
|
36
|
+
// 아직 안 켠 상태다. 장애가 아니므로 "다시 시도"라고 하지 않는다 —
|
|
37
|
+
// 몇 번을 눌러도 달라지지 않고, 기다릴 일도 아니다.
|
|
38
|
+
answer_disabled:
|
|
39
|
+
"AI reply suggestions are not enabled yet. Only inquiry collection is running.",
|
|
40
|
+
serving_disabled:
|
|
41
|
+
"AI reply suggestions are not enabled yet. Only inquiry collection is running.",
|
|
42
|
+
answer_unavailable: "Could not reach the reply service. Please try again shortly.",
|
|
43
|
+
network_error: "Could not reach the reply service. Please try again shortly.",
|
|
44
|
+
gate_blocked:
|
|
45
|
+
"Not enough approved manuals or sources yet, so no reply was suggested. This improves automatically as manuals are approved.",
|
|
46
|
+
no_evidence: "No sufficiently similar source was found for this inquiry.",
|
|
47
|
+
contract_violation: "The draft did not pass the quality checks, so it was held back.",
|
|
48
|
+
http_error: "The reply service could not process the request. Please try again shortly.",
|
|
49
|
+
unknown: "No reply could be suggested.",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ko = {
|
|
53
|
+
ui_request: "AI 답변 제안",
|
|
54
|
+
ui_requesting: "제안 생성 중...",
|
|
55
|
+
ui_adopt: "에디터에 넣기",
|
|
56
|
+
ui_evidence: "근거",
|
|
57
|
+
not_configured: "AI 답변 제안이 아직 연결되지 않았습니다.",
|
|
58
|
+
unsupported_api: "AI 답변 제안이 아직 연결되지 않았습니다.",
|
|
59
|
+
answer_disabled: "AI 답변 제안은 아직 켜지지 않았습니다. 문의·답변 수집만 진행 중입니다.",
|
|
60
|
+
serving_disabled: "AI 답변 제안은 아직 켜지지 않았습니다. 문의·답변 수집만 진행 중입니다.",
|
|
61
|
+
answer_unavailable: "응답 서버에 연결하지 못했습니다. 잠시 후 다시 시도해 주세요.",
|
|
62
|
+
network_error: "응답 서버에 연결하지 못했습니다. 잠시 후 다시 시도해 주세요.",
|
|
63
|
+
gate_blocked:
|
|
64
|
+
"아직 승인된 매뉴얼·자료 출처가 부족해서 답변을 제안하지 못했습니다. 매뉴얼 승인이 쌓이면 자동으로 좋아집니다.",
|
|
65
|
+
no_evidence: "이 문의와 충분히 비슷한 근거를 찾지 못했습니다.",
|
|
66
|
+
contract_violation: "제안 문구가 품질 기준을 통과하지 못해 보류했습니다.",
|
|
67
|
+
http_error: "응답 서버가 요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.",
|
|
68
|
+
unknown: "답변을 제안하지 못했습니다.",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const ja = {
|
|
72
|
+
ui_request: "AI 返信案",
|
|
73
|
+
ui_requesting: "生成中...",
|
|
74
|
+
ui_adopt: "エディタに挿入",
|
|
75
|
+
ui_evidence: "根拠",
|
|
76
|
+
not_configured: "AI 返信案はまだ接続されていません。",
|
|
77
|
+
unsupported_api: "AI 返信案はまだ接続されていません。",
|
|
78
|
+
answer_disabled: "AI 返信案はまだ有効になっていません。問い合わせの収集のみ実行中です。",
|
|
79
|
+
serving_disabled: "AI 返信案はまだ有効になっていません。問い合わせの収集のみ実行中です。",
|
|
80
|
+
answer_unavailable: "応答サーバーに接続できませんでした。しばらくしてからもう一度お試しください。",
|
|
81
|
+
network_error: "応答サーバーに接続できませんでした。しばらくしてからもう一度お試しください。",
|
|
82
|
+
gate_blocked:
|
|
83
|
+
"承認済みのマニュアル・資料がまだ足りないため、返信案を作成できませんでした。マニュアルの承認が増えると自動的に改善します。",
|
|
84
|
+
no_evidence: "この問い合わせに十分に近い根拠が見つかりませんでした。",
|
|
85
|
+
contract_violation: "返信案が品質基準を満たさなかったため保留しました。",
|
|
86
|
+
http_error: "応答サーバーがリクエストを処理できませんでした。しばらくしてからもう一度お試しください。",
|
|
87
|
+
unknown: "返信案を作成できませんでした。",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const zhTW = {
|
|
91
|
+
ui_request: "AI 回覆建議",
|
|
92
|
+
ui_requesting: "產生中...",
|
|
93
|
+
ui_adopt: "插入編輯器",
|
|
94
|
+
ui_evidence: "依據",
|
|
95
|
+
not_configured: "AI 回覆建議尚未連接。",
|
|
96
|
+
unsupported_api: "AI 回覆建議尚未連接。",
|
|
97
|
+
answer_disabled: "AI 回覆建議尚未啟用,目前僅進行問題收集。",
|
|
98
|
+
serving_disabled: "AI 回覆建議尚未啟用,目前僅進行問題收集。",
|
|
99
|
+
answer_unavailable: "無法連接回覆伺服器,請稍後再試。",
|
|
100
|
+
network_error: "無法連接回覆伺服器,請稍後再試。",
|
|
101
|
+
gate_blocked:
|
|
102
|
+
"已核准的手冊或資料來源尚不足,因此未提供回覆建議。隨著手冊核准累積會自動改善。",
|
|
103
|
+
no_evidence: "找不到與此問題足夠相似的依據。",
|
|
104
|
+
contract_violation: "建議內容未通過品質標準,已暫緩提供。",
|
|
105
|
+
http_error: "回覆伺服器無法處理此請求,請稍後再試。",
|
|
106
|
+
unknown: "無法提供回覆建議。",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const MESSAGES = { en, ko, ja, "zh-TW": zhTW };
|
|
110
|
+
|
|
111
|
+
/** "ko-KR" · "KO" · "zh_TW" 같은 표기를 지원 로케일로 정규화. 모르면 기본값. */
|
|
112
|
+
export function normalizeLocale(locale) {
|
|
113
|
+
const raw = String(locale || "").trim().replace("_", "-");
|
|
114
|
+
if (!raw) return DEFAULT_LOCALE;
|
|
115
|
+
const lower = raw.toLowerCase();
|
|
116
|
+
for (const known of LOCALES) {
|
|
117
|
+
if (known.toLowerCase() === lower) return known;
|
|
118
|
+
}
|
|
119
|
+
// 지역 없는 표기로 재시도 (ko-KR → ko). 중국어는 번체/간체가 갈리므로
|
|
120
|
+
// 언어만으로 zh-TW 에 붙이지 않는다 — 간체 사용자에게 번체를 보이면 안 된다.
|
|
121
|
+
const lang = lower.split("-")[0];
|
|
122
|
+
for (const known of LOCALES) {
|
|
123
|
+
if (known.toLowerCase() === lang) return known;
|
|
124
|
+
}
|
|
125
|
+
return DEFAULT_LOCALE;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 로케일 문구 한 벌 + 소비처가 덮어쓴 값.
|
|
129
|
+
*
|
|
130
|
+
* 빠진 키는 기본 로케일로 메운다 — 번역이 늦은 항목이 화면에서 `undefined` 로
|
|
131
|
+
* 새는 것을 막는다.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveMessages(locale, overrides) {
|
|
134
|
+
const base = MESSAGES[normalizeLocale(locale)] || MESSAGES[DEFAULT_LOCALE];
|
|
135
|
+
return { ...MESSAGES[DEFAULT_LOCALE], ...base, ...(overrides || {}) };
|
|
136
|
+
}
|
package/next.d.ts
ADDED
package/next.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AI 답변 제안 패널 — Next.js (App Router).
|
|
5
|
+
*
|
|
6
|
+
* ```jsx
|
|
7
|
+
* import { AiSuggestPanel } from "@fcg-labs/cx-agent-hook/next";
|
|
8
|
+
* import "@fcg-labs/cx-agent-hook/styles.css";
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* **왜 별도 진입점인가.** App Router 는 기본이 서버 컴포넌트라 `useState`·
|
|
12
|
+
* `useEffect` 를 쓰는 이 패널이 그대로는 못 뜬다. 소비처가 자기 파일마다
|
|
13
|
+
* `"use client"` 를 적게 하는 대신, 경계를 **라이브러리가 선언**한다 —
|
|
14
|
+
* 그 지시자를 어디에 둬야 하는지도 제품 지식이고, 빠뜨리면 빌드가 아니라
|
|
15
|
+
* 런타임에서 깨진다.
|
|
16
|
+
*
|
|
17
|
+
* Pages Router 나 순수 React 라면 `@fcg-labs/cx-agent-hook/react` 와 같다.
|
|
18
|
+
* 지시자는 그쪽에서 무시되므로 이 진입점을 써도 문제는 없다.
|
|
19
|
+
*/
|
|
20
|
+
export { AiSuggestPanel, aiSuggestView, default } from "./react.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fcg-labs/cx-agent-hook",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"import": "./index.js"
|
|
12
|
+
},
|
|
13
|
+
"./react": {
|
|
14
|
+
"types": "./react.d.ts",
|
|
15
|
+
"import": "./react.js"
|
|
16
|
+
},
|
|
17
|
+
"./next": {
|
|
18
|
+
"types": "./next.d.ts",
|
|
19
|
+
"import": "./next.js"
|
|
20
|
+
},
|
|
21
|
+
"./vue": {
|
|
22
|
+
"types": "./vue.d.ts",
|
|
23
|
+
"import": "./vue.js"
|
|
24
|
+
},
|
|
25
|
+
"./astro": {
|
|
26
|
+
"types": "./astro.d.ts",
|
|
27
|
+
"import": "./astro.js"
|
|
28
|
+
},
|
|
29
|
+
"./angular": {
|
|
30
|
+
"types": "./angular.d.ts",
|
|
31
|
+
"import": "./angular.js"
|
|
32
|
+
},
|
|
33
|
+
"./element": {
|
|
34
|
+
"types": "./element.d.ts",
|
|
35
|
+
"import": "./element.js"
|
|
36
|
+
},
|
|
37
|
+
"./styles.css": "./styles.css"
|
|
38
|
+
},
|
|
39
|
+
"//exports": "client.js(전송 계층)는 의도적으로 맵에 없다 — 소비처가 저수준으로 내려가면 answer_id 수명 관리 같은 제품 지식이 고객사 코드로 흩어진다. 능력이 모자라면 createCxHook 표면을 채운다.",
|
|
40
|
+
"//peerDependencies": "react 는 './react' 서브패스에서만 필요하다. 본체(전송 계층)는 여전히 의존성 0 이고, React 를 안 쓰는 소비처는 './react' 를 import 하지 않으면 그만이다 (optional).",
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": ">=16.8",
|
|
43
|
+
"vue": ">=3.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"react": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"vue": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"index.js",
|
|
55
|
+
"index.d.ts",
|
|
56
|
+
"client.js",
|
|
57
|
+
"client.d.ts",
|
|
58
|
+
"view.js",
|
|
59
|
+
"view.d.ts",
|
|
60
|
+
"locales.js",
|
|
61
|
+
"locales.d.ts",
|
|
62
|
+
"react.js",
|
|
63
|
+
"react.d.ts",
|
|
64
|
+
"next.js",
|
|
65
|
+
"next.d.ts",
|
|
66
|
+
"vue.js",
|
|
67
|
+
"vue.d.ts",
|
|
68
|
+
"astro.js",
|
|
69
|
+
"astro.d.ts",
|
|
70
|
+
"angular.js",
|
|
71
|
+
"angular.d.ts",
|
|
72
|
+
"element.js",
|
|
73
|
+
"element.d.ts",
|
|
74
|
+
"styles.css",
|
|
75
|
+
"README.md",
|
|
76
|
+
"LICENSE"
|
|
77
|
+
],
|
|
78
|
+
"scripts": {
|
|
79
|
+
"test": "node --test test/*.test.js",
|
|
80
|
+
"prepublishOnly": "node scripts/prepublish-guard.mjs"
|
|
81
|
+
},
|
|
82
|
+
"engines": {
|
|
83
|
+
"node": ">=18"
|
|
84
|
+
},
|
|
85
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
86
|
+
"author": "FCG (FCG-labs)",
|
|
87
|
+
"repository": {
|
|
88
|
+
"type": "git",
|
|
89
|
+
"url": "https://github.com/FCG-labs/FCG-CX-Agent.git",
|
|
90
|
+
"directory": "sdk/cx-agent-hook"
|
|
91
|
+
},
|
|
92
|
+
"//frameworks": "astro·angular 는 표준 커스텀 엘리먼트(./element)를 쓴다. 프레임워크별 컴포넌트를 따로 짜면 메이저 버전마다 우리가 따라다니게 되고, 빌드 단계 없음(의존성 0) 성질도 깨진다.",
|
|
93
|
+
"devDependencies": {
|
|
94
|
+
"@vue/server-renderer": "^3.5.40",
|
|
95
|
+
"happy-dom": "^20.11.1",
|
|
96
|
+
"vue": "^3.5.40"
|
|
97
|
+
},
|
|
98
|
+
"publishConfig": {
|
|
99
|
+
"access": "public"
|
|
100
|
+
},
|
|
101
|
+
"//name": "스코프는 @fcg-labs — org 소유이고 @fcg-labs/tlm 과 같은 자리다. @fcg 는 우리 것이 아니다(발행 불가)."
|
|
102
|
+
}
|
package/react.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { AnswerResult, CxHook } from "./index.js";
|
|
3
|
+
|
|
4
|
+
export { aiSuggestView } from "./view.js";
|
|
5
|
+
export type { AiSuggestView } from "./view.js";
|
|
6
|
+
|
|
7
|
+
export interface AiSuggestPanelProps {
|
|
8
|
+
hook: CxHook;
|
|
9
|
+
inquiry: string | undefined | null;
|
|
10
|
+
onAdopt?: (text: string) => void;
|
|
11
|
+
/** 바깥 배치용 (레이아웃만 — 색·간격은 styles.css 변수로) */
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** AI 답변 제안 패널. hook 이 미설정이면 아무것도 그리지 않는다. */
|
|
16
|
+
export declare function AiSuggestPanel(props: AiSuggestPanelProps): ReactElement | null;
|
|
17
|
+
|
|
18
|
+
export default AiSuggestPanel;
|
package/react.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 답변 제안 패널 — 상담사 화면에 그대로 꽂는 제품 UI.
|
|
3
|
+
*
|
|
4
|
+
* 왜 라이브러리에 있나: 이 패널의 상태 전이(누름→생성중→결과), 거절 사유 안내,
|
|
5
|
+
* 근거 표기, 채택 시 answer_id 를 발송 후킹에 잇는 규약은 **전부 제품 지식**이다.
|
|
6
|
+
* 고객사 화면에 손으로 짜 두면 2호 고객이 올 때 또 짜야 하고, 규약이 바뀌면
|
|
7
|
+
* 고객사마다 따로 고쳐야 한다.
|
|
8
|
+
*
|
|
9
|
+
* ```jsx
|
|
10
|
+
* import { AiSuggestPanel } from "@fcg-labs/cx-agent-hook/react";
|
|
11
|
+
* import "@fcg-labs/cx-agent-hook/styles.css";
|
|
12
|
+
*
|
|
13
|
+
* <AiSuggestPanel hook={cxHook} inquiry={selected?.content} onAdopt={setAnswerText} />
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* React 는 peer 다 (>=16.8). 여기서는 JSX 를 쓰지 않는다 — 이 패키지는 빌드
|
|
17
|
+
* 단계가 없고(의존성 0 이 설계 성질), JSX 를 그대로 배포하면 소비처 번들러가
|
|
18
|
+
* node_modules 를 변환해 주기를 기대해야 한다. createElement 로 직접 쓴다.
|
|
19
|
+
*/
|
|
20
|
+
import { createElement as h, useCallback, useEffect, useState } from "react";
|
|
21
|
+
|
|
22
|
+
import { aiSuggestView, CLS } from "./view.js";
|
|
23
|
+
|
|
24
|
+
// 표시 판단은 프레임워크 중립 코어가 갖는다 — Vue·웹 컴포넌트와 같은 것을 쓴다.
|
|
25
|
+
export { aiSuggestView };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} props
|
|
29
|
+
* @param {object} props.hook createCxHook() 결과
|
|
30
|
+
* @param {string} props.inquiry 대상 문의 원문
|
|
31
|
+
* @param {(text: string) => void} props.onAdopt "에디터에 넣기"
|
|
32
|
+
* @param {string} [props.className] 바깥 배치용 (레이아웃만)
|
|
33
|
+
*/
|
|
34
|
+
export function AiSuggestPanel({ hook, inquiry, onAdopt, className }) {
|
|
35
|
+
const [state, setState] = useState("idle");
|
|
36
|
+
const [result, setResult] = useState(null);
|
|
37
|
+
|
|
38
|
+
// 다른 문의로 옮기면 앞 문의의 제안이 남아 있으면 안 된다. 채택 기록도 같이
|
|
39
|
+
// 지운다 — 안 지우면 다음 문의를 발송할 때 엉뚱한 answer_id 로 후킹된다.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
setState("idle");
|
|
42
|
+
setResult(null);
|
|
43
|
+
hook.clearAdopted();
|
|
44
|
+
}, [inquiry, hook]);
|
|
45
|
+
|
|
46
|
+
const request = useCallback(async () => {
|
|
47
|
+
if (!inquiry || state === "loading") return;
|
|
48
|
+
setState("loading");
|
|
49
|
+
setResult(await hook.requestAnswer(inquiry));
|
|
50
|
+
setState("done");
|
|
51
|
+
}, [hook, inquiry, state]);
|
|
52
|
+
|
|
53
|
+
const adopt = useCallback(() => {
|
|
54
|
+
if (!result || !result.answered) return;
|
|
55
|
+
hook.noteAdopted(result.answerId);
|
|
56
|
+
if (onAdopt) onAdopt(result.answer);
|
|
57
|
+
}, [hook, onAdopt, result]);
|
|
58
|
+
|
|
59
|
+
if (!hook || !hook.enabled) return null;
|
|
60
|
+
|
|
61
|
+
const view = aiSuggestView({
|
|
62
|
+
state, result, declineText: hook.declineText, messages: hook.messages,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return h(
|
|
66
|
+
"div",
|
|
67
|
+
{
|
|
68
|
+
className: className ? `${CLS.root} ${className}` : CLS.root,
|
|
69
|
+
// 이 패널은 보통 "클릭하면 선택되는" 문의 행 안에 놓인다. 버튼을 누르려다
|
|
70
|
+
// 행 선택이 토글되면 제안이 초기화된다.
|
|
71
|
+
onClick: (event) => event.stopPropagation(),
|
|
72
|
+
},
|
|
73
|
+
h(
|
|
74
|
+
"div",
|
|
75
|
+
{ className: CLS.head },
|
|
76
|
+
h(
|
|
77
|
+
"button",
|
|
78
|
+
{
|
|
79
|
+
type: "button",
|
|
80
|
+
className: CLS.button,
|
|
81
|
+
disabled: view.buttonDisabled,
|
|
82
|
+
onClick: request,
|
|
83
|
+
},
|
|
84
|
+
view.buttonLabel,
|
|
85
|
+
),
|
|
86
|
+
view.showAdopt &&
|
|
87
|
+
h(
|
|
88
|
+
"button",
|
|
89
|
+
{ type: "button", className: CLS.adopt, onClick: adopt },
|
|
90
|
+
view.adoptLabel,
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
view.body.kind === "answer" &&
|
|
94
|
+
h(
|
|
95
|
+
"div",
|
|
96
|
+
{ className: CLS.body },
|
|
97
|
+
h("div", { className: CLS.answer }, view.body.text),
|
|
98
|
+
view.body.evidence.length > 0 &&
|
|
99
|
+
h(
|
|
100
|
+
"div",
|
|
101
|
+
{ className: CLS.evidence },
|
|
102
|
+
`${view.body.evidenceLabel}: ${view.body.evidence.join(" · ")}`,
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
view.body.kind === "declined" &&
|
|
106
|
+
h("div", { className: CLS.declined }, view.body.text),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default AiSuggestPanel;
|
package/styles.css
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* AI 답변 제안 패널 기본 스타일.
|
|
2
|
+
*
|
|
3
|
+
* 클래스는 `fcx-` 로 시작한다 — 소비처(고객사 관리자 화면)의 이름 공간과
|
|
4
|
+
* 겹치지 않게 하기 위해서다. 배치(margin·위치)는 여기서 정하지 않고 소비처가
|
|
5
|
+
* `className` 으로 준다. 색·간격을 바꾸려면 아래 변수만 덮어쓰면 된다.
|
|
6
|
+
*/
|
|
7
|
+
.fcx-ai {
|
|
8
|
+
--fcx-accent: #2f6fed;
|
|
9
|
+
--fcx-surface: #f5f9ff;
|
|
10
|
+
--fcx-border: #d7e3f4;
|
|
11
|
+
--fcx-text: #1f2d3d;
|
|
12
|
+
--fcx-muted: #5b6b7e;
|
|
13
|
+
--fcx-warn: #8a5a00;
|
|
14
|
+
|
|
15
|
+
padding: 10px 12px;
|
|
16
|
+
border: 1px solid var(--fcx-border);
|
|
17
|
+
border-radius: 10px;
|
|
18
|
+
background: var(--fcx-surface);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.fcx-ai-head { display: flex; gap: 8px; align-items: center; }
|
|
22
|
+
|
|
23
|
+
.fcx-ai-button,
|
|
24
|
+
.fcx-ai-adopt {
|
|
25
|
+
padding: 6px 12px;
|
|
26
|
+
border: 1px solid var(--fcx-accent);
|
|
27
|
+
border-radius: 8px;
|
|
28
|
+
font-size: 12px;
|
|
29
|
+
font-weight: 600;
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.fcx-ai-button { background: var(--fcx-accent); color: #fff; }
|
|
34
|
+
.fcx-ai-button:disabled { opacity: 0.6; cursor: default; }
|
|
35
|
+
.fcx-ai-adopt { background: #fff; color: var(--fcx-accent); }
|
|
36
|
+
|
|
37
|
+
.fcx-ai-body { margin-top: 8px; }
|
|
38
|
+
.fcx-ai-answer { white-space: pre-wrap; font-size: 13px; color: var(--fcx-text); }
|
|
39
|
+
.fcx-ai-evidence { margin-top: 6px; font-size: 11px; color: var(--fcx-muted); }
|
|
40
|
+
.fcx-ai-declined { margin-top: 8px; font-size: 12px; color: var(--fcx-warn); }
|
package/view.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** 표시 판단과 클래스 이름 — 프레임워크 중립.
|
|
2
|
+
* React·Vue·웹 컴포넌트가 같은 판단을 쓴다. */
|
|
3
|
+
import type { AnswerResult } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export interface AiSuggestView {
|
|
6
|
+
buttonLabel: string;
|
|
7
|
+
buttonDisabled: boolean;
|
|
8
|
+
showAdopt: boolean;
|
|
9
|
+
adoptLabel: string;
|
|
10
|
+
body:
|
|
11
|
+
| { kind: "none" }
|
|
12
|
+
| { kind: "answer"; text: string; evidence: string[]; evidenceLabel: string }
|
|
13
|
+
| { kind: "declined"; text: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export declare function aiSuggestView(input: {
|
|
17
|
+
state: "idle" | "loading" | "done";
|
|
18
|
+
result: AnswerResult | null;
|
|
19
|
+
declineText: (reason: string) => string;
|
|
20
|
+
messages?: Record<string, string>;
|
|
21
|
+
}): AiSuggestView;
|
|
22
|
+
|
|
23
|
+
/** 세 어댑터가 공유하는 클래스 이름 (styles.css 와 짝) */
|
|
24
|
+
export declare const CLS: Record<
|
|
25
|
+
"root" | "head" | "button" | "adopt" | "body" | "answer" | "evidence" | "declined",
|
|
26
|
+
string
|
|
27
|
+
>;
|