@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/MIGRATION.md +116 -0
- package/README.md +55 -0
- package/agent.d.ts +197 -0
- package/agent.js +258 -0
- package/client.d.ts +4 -1
- package/client.js +124 -2
- package/compat.js +67 -0
- package/element.js +25 -43
- package/index.js +10 -328
- package/locales.js +56 -0
- package/package.json +10 -1
- package/react.js +17 -36
- package/session.js +455 -0
- package/styles.css +91 -27
- package/view.d.ts +36 -0
- package/view.js +142 -0
- package/vue.js +18 -31
package/client.js
CHANGED
|
@@ -37,6 +37,8 @@ const PATHS = {
|
|
|
37
37
|
ingress: (d) => `/v1/domains/${d}/ingress`,
|
|
38
38
|
// 조사 접점 (E-8) — 트리거는 큐 적재만, 상태는 경량 메타만 (허브 계약)
|
|
39
39
|
investigation: (d) => `/v1/domains/${d}/investigations`,
|
|
40
|
+
// 처리 액션 (actions/1) — 실행 요청·상태·재조정. 정본: docs/contracts/action-contract-v1.md
|
|
41
|
+
actions: (d) => `/v1/domains/${d}/actions`,
|
|
40
42
|
},
|
|
41
43
|
// platform 은 인그레스 API 미제공 (공장 큐레이션 파이프라인이 담당)
|
|
42
44
|
};
|
|
@@ -113,7 +115,7 @@ export class CxAgentClient {
|
|
|
113
115
|
*/
|
|
114
116
|
constructor({ baseUrl, token, domain, api = "platform", retries = 2,
|
|
115
117
|
timeoutMs = 90000, onError, fetchImpl,
|
|
116
|
-
answer, ingress, feedback } = {}) {
|
|
118
|
+
answer, ingress, feedback, source = "cms" } = {}) {
|
|
117
119
|
if (!baseUrl || !token || !domain) {
|
|
118
120
|
throw new Error("@fcg-labs/cx-agent-hook: baseUrl·token·domain 은 필수입니다");
|
|
119
121
|
}
|
|
@@ -140,6 +142,9 @@ export class CxAgentClient {
|
|
|
140
142
|
}
|
|
141
143
|
this.retries = retries;
|
|
142
144
|
this.timeoutMs = timeoutMs;
|
|
145
|
+
// 인그레스 body.source — 문의가 어느 채널에서 왔는지의 테넌트 선언.
|
|
146
|
+
// "cms" 하드코딩이던 것을 0.3.0 에서 파라미터화 (기본값은 하위호환).
|
|
147
|
+
this.source = String(source || "cms");
|
|
143
148
|
this.onError = onError || (() => {});
|
|
144
149
|
this.fetchImpl = fetchImpl || globalThis.fetch.bind(globalThis);
|
|
145
150
|
}
|
|
@@ -485,7 +490,7 @@ export class CxAgentClient {
|
|
|
485
490
|
return this._inqFail(new Error("inquiry 필수"), "invalid_inquiry");
|
|
486
491
|
}
|
|
487
492
|
const body = {
|
|
488
|
-
source:
|
|
493
|
+
source: this.source,
|
|
489
494
|
external_id: String(externalId),
|
|
490
495
|
inquiry: String(inquiry),
|
|
491
496
|
...(reply ? { reply: String(reply) } : {}),
|
|
@@ -588,6 +593,123 @@ export class CxAgentClient {
|
|
|
588
593
|
clearTimeout(timer);
|
|
589
594
|
}
|
|
590
595
|
}
|
|
596
|
+
|
|
597
|
+
// ── 처리 액션 (actions/1) ──────────────────────────────────────────────
|
|
598
|
+
//
|
|
599
|
+
// **재시도 0.** sendFeedback 의 5xx 재시도 루프를 여기 쓰면 비멱등 실행이 두 번
|
|
600
|
+
// 간다. 네트워크 오류면 호출자가 actionStatus(request_id) 로 원장을 묻고,
|
|
601
|
+
// 404(미도달 확정)일 때만 같은 request_id 로 1회 재전송한다 (client 밖 규칙).
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* 실행 요청. request_id 는 호출자(세션)가 발급·영속한 뒤 넘긴다 — 멱등 키.
|
|
605
|
+
* @returns {Promise<{ok:boolean, state:string, requestId:string, reasonCode:string,
|
|
606
|
+
* message:string, actorAttested:string, auditRef:string, result:object|null,
|
|
607
|
+
* deduplicated:boolean, error:string|null, httpStatus:number}>}
|
|
608
|
+
*/
|
|
609
|
+
async requestAction({ requestId, offer, actorAssertion, actorClaimed, inquiryRef } = {}) {
|
|
610
|
+
if (this.api !== "hub" || !requestId || !offer || !offer.offer_id) {
|
|
611
|
+
return { ok: false, state: "", requestId: requestId || "", reasonCode: "",
|
|
612
|
+
message: "", actorAttested: "", auditRef: "", result: null,
|
|
613
|
+
deduplicated: false, error: "unsupported", httpStatus: 0 };
|
|
614
|
+
}
|
|
615
|
+
const body = {
|
|
616
|
+
request_id: String(requestId), offer_id: String(offer.offer_id),
|
|
617
|
+
event_id: String(offer.event_id || ""), action_key: String(offer.action_key || ""),
|
|
618
|
+
params_hash: String(offer.params_hash || ""), params: offer.params_bound || {},
|
|
619
|
+
actor_assertion: String(actorAssertion || ""), actor_claimed: String(actorClaimed || ""),
|
|
620
|
+
inquiry_ref: inquiryRef != null ? String(inquiryRef) : "",
|
|
621
|
+
confirmed_at: new Date().toISOString(),
|
|
622
|
+
};
|
|
623
|
+
try {
|
|
624
|
+
const { status, data } = await this._post(PATHS.hub.actions(this.domain), body);
|
|
625
|
+
const view = (d) => ({
|
|
626
|
+
state: d.state || "", requestId: d.request_id || String(requestId),
|
|
627
|
+
reasonCode: d.reason_code || "", message: d.message || "",
|
|
628
|
+
actorAttested: d.actor_attested || "", auditRef: d.audit_ref || "",
|
|
629
|
+
result: d.result ?? null, deduplicated: Boolean(d.deduplicated),
|
|
630
|
+
});
|
|
631
|
+
if (status === 200 || status === 202) {
|
|
632
|
+
return { ok: true, ...view(data), error: null, httpStatus: status };
|
|
633
|
+
}
|
|
634
|
+
// 409 offer_locked / request_conflict, 422 계약 위반, 503 잠김 — 그대로 전달
|
|
635
|
+
this.onError(new Error(`HTTP ${status}`), { op: "requestAction", code: data.error || "http_error" });
|
|
636
|
+
return { ok: false, ...view(data), error: data.error || `http_${status}`, httpStatus: status };
|
|
637
|
+
} catch (err) {
|
|
638
|
+
this.onError(err, { op: "requestAction", code: "network_error" });
|
|
639
|
+
return { ok: false, state: "", requestId: String(requestId), reasonCode: "", message: "",
|
|
640
|
+
actorAttested: "", auditRef: "", result: null, deduplicated: false,
|
|
641
|
+
error: "network_error", httpStatus: 0 };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** 상태 조회 — 원장 뷰(result 없음). 404 = 허브에 기록 없음(미도달 확정). */
|
|
646
|
+
async actionStatus(requestId) {
|
|
647
|
+
if (this.api !== "hub" || !requestId) return { ok: false, state: "", error: "unsupported" };
|
|
648
|
+
return this._getJson(
|
|
649
|
+
PATHS.hub.actions(this.domain) + `/${encodeURIComponent(String(requestId))}`,
|
|
650
|
+
"actionStatus",
|
|
651
|
+
(status, data) => {
|
|
652
|
+
if (status === 200) return { ok: true, state: data.state || "", reasonCode: data.reason_code || "",
|
|
653
|
+
message: data.message || "", error: null };
|
|
654
|
+
if (status === 404) return { ok: true, state: "none", reasonCode: "", message: "", error: null };
|
|
655
|
+
return { ok: false, state: "", error: data.error || `http_${status}` };
|
|
656
|
+
},
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** 재조정 — lookup 전용 (재실행 아님). unknown 만 의미 있고 종단이면 그대로 온다. */
|
|
661
|
+
async actionReconcile(requestId) {
|
|
662
|
+
if (this.api !== "hub" || !requestId) return { ok: false, state: "", error: "unsupported" };
|
|
663
|
+
try {
|
|
664
|
+
const { status, data } = await this._post(
|
|
665
|
+
PATHS.hub.actions(this.domain) + `/${encodeURIComponent(String(requestId))}/reconcile`, {},
|
|
666
|
+
);
|
|
667
|
+
if (status === 200 || status === 202) {
|
|
668
|
+
return { ok: true, state: data.state || "", reasonCode: data.reason_code || "",
|
|
669
|
+
message: data.message || "", actorAttested: data.actor_attested || "",
|
|
670
|
+
auditRef: data.audit_ref || "", error: null };
|
|
671
|
+
}
|
|
672
|
+
return { ok: false, state: "", error: data.error || `http_${status}` };
|
|
673
|
+
} catch (err) {
|
|
674
|
+
this.onError(err, { op: "actionReconcile", code: "network_error" });
|
|
675
|
+
return { ok: false, state: "", error: "network_error" };
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async _getJson(path, op, interpret) {
|
|
680
|
+
const controller = new AbortController();
|
|
681
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
682
|
+
try {
|
|
683
|
+
const res = await this.fetchImpl(this.baseUrl + path, {
|
|
684
|
+
headers: { "Authorization": `Bearer ${this.token}` }, signal: controller.signal,
|
|
685
|
+
});
|
|
686
|
+
const data = await res.json().catch(() => ({}));
|
|
687
|
+
return interpret(res.status, data);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
this.onError(err, { op, code: "network_error" });
|
|
690
|
+
return { ok: false, state: "", error: "network_error" };
|
|
691
|
+
} finally {
|
|
692
|
+
clearTimeout(timer);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** ULID (Crockford 26자) — request_id 발급. 의존성 0 (공장 event_ids 와 동형). */
|
|
698
|
+
export function newRequestId(now = Date.now()) {
|
|
699
|
+
const A = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
700
|
+
let ts = BigInt(now) & ((1n << 48n) - 1n);
|
|
701
|
+
const rnd = new Uint8Array(10);
|
|
702
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) crypto.getRandomValues(rnd);
|
|
703
|
+
else for (let i = 0; i < 10; i++) rnd[i] = Math.floor(Math.random() * 256);
|
|
704
|
+
// 상위 48bit 시각 → 10자, 하위 80bit 무작위 → 16자 (공장 event_ids 와 동일 배치)
|
|
705
|
+
const out = [];
|
|
706
|
+
let t = ts;
|
|
707
|
+
for (let i = 0; i < 10; i++) { out.unshift(A[Number(t & 31n)]); t >>= 5n; }
|
|
708
|
+
let r = 0n;
|
|
709
|
+
for (const b of rnd) r = (r << 8n) | BigInt(b);
|
|
710
|
+
const tail = [];
|
|
711
|
+
for (let i = 0; i < 16; i++) { tail.unshift(A[Number(r & 31n)]); r >>= 5n; }
|
|
712
|
+
return out.join("") + tail.join("");
|
|
591
713
|
}
|
|
592
714
|
|
|
593
715
|
export default CxAgentClient;
|
package/compat.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createCxHook — 0.2.x 호환 표면 (내부용 모듈, exports 맵에 없다).
|
|
3
|
+
*
|
|
4
|
+
* 0.3.0 에서 구현이 CxAgent + InquirySession 으로 옮겨졌다. 이 파일은 그
|
|
5
|
+
* 위에 0.2.x 문서화 표면을 **비영속 기본 세션** 하나로 재구성한다 — 기존
|
|
6
|
+
* 소비처(와 시험 94건)는 글자 하나 안 바꾸고 그대로 동작한다.
|
|
7
|
+
*
|
|
8
|
+
* 0.2.x 와 0.3.0 의 관계:
|
|
9
|
+
* - hook.noteAdopted/answerSent/... = 기본 세션의 동명 메서드
|
|
10
|
+
* - hook.composeDraft = session.compose + 호출별 UI sink
|
|
11
|
+
* - 문의별 상태·영속이 필요하면 `createCxAgent` + `agent.session()` 으로
|
|
12
|
+
* 올라간다 (MIGRATION.md)
|
|
13
|
+
*/
|
|
14
|
+
import { createCxAgent, notConfiguredResult, textOf } from "./agent.js";
|
|
15
|
+
|
|
16
|
+
/** 후킹 한 벌을 만든다 — 미설정이면 전부 무동작. (0.2.x 표면 그대로) */
|
|
17
|
+
export function createCxHook(config = {}) {
|
|
18
|
+
const agent = createCxAgent({
|
|
19
|
+
...config,
|
|
20
|
+
// 0.2.x 훅은 문의별 세션 개념이 없다 — 영속은 세션 표면의 능력이다.
|
|
21
|
+
storage: { enabled: false, ...(config.storage || {}) },
|
|
22
|
+
});
|
|
23
|
+
// 훅 전체가 공유하는 단일 채택 슬롯 = 비영속 기본 세션 하나
|
|
24
|
+
const session = agent.session();
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
enabled: agent.enabled,
|
|
28
|
+
ready: agent.ready,
|
|
29
|
+
get flags() { return agent.flags; },
|
|
30
|
+
locale: agent.locale,
|
|
31
|
+
messages: agent.messages,
|
|
32
|
+
declineText(reason) { return agent.declineText(reason); },
|
|
33
|
+
|
|
34
|
+
requestAnswer(inquiry, context) {
|
|
35
|
+
return agent.requestAnswer(inquiry, context);
|
|
36
|
+
},
|
|
37
|
+
requestInvestigation(externalId, identity) {
|
|
38
|
+
return agent.requestInvestigation(externalId, identity);
|
|
39
|
+
},
|
|
40
|
+
investigationStatus(externalId) {
|
|
41
|
+
return agent.investigationStatus(externalId);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/** 초안 직주입 스트리밍 — 세션 compose 에 호출별 UI sink 를 붙인다.
|
|
45
|
+
* 의미론(자동 채택·교체 기각·실패 원복·abort 무간섭)은 session.js 소유. */
|
|
46
|
+
composeDraft({ inquiry, context, getDraft, setDraft, setStatus,
|
|
47
|
+
confirmOverwrite, customerName } = {}) {
|
|
48
|
+
if (!agent.enabled || !inquiry || typeof setDraft !== "function") {
|
|
49
|
+
if (setStatus) setStatus(textOf(agent.messages, "not_configured"));
|
|
50
|
+
return { promise: Promise.resolve(notConfiguredResult()), abort: () => {} };
|
|
51
|
+
}
|
|
52
|
+
session.attachUi({ getDraft, setDraft, setStatus });
|
|
53
|
+
return session.compose({ inquiry, context, customerName, confirmOverwrite });
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
noteAdopted(answerId) { session.noteAdopted(answerId); },
|
|
57
|
+
clearAdopted() { session.clearAdopted(); },
|
|
58
|
+
get adoptedAnswerId() { return session.adoptedAnswerId; },
|
|
59
|
+
|
|
60
|
+
answerSent(finalText, agentId) { session.answerSent(finalText, agentId); },
|
|
61
|
+
scored(score, agentId) { session.scored(score, agentId); },
|
|
62
|
+
edited(finalText, agentId) { session.edited(finalText, agentId); },
|
|
63
|
+
discarded(agentId, note) { session.discarded(agentId, note); },
|
|
64
|
+
|
|
65
|
+
inquirySent(payload) { agent.inquirySent(payload); },
|
|
66
|
+
};
|
|
67
|
+
}
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
node.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
};
|