@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/MIGRATION.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# 0.3.0 → 0.4.0 마이그레이션
|
|
2
|
+
|
|
3
|
+
## 요약 — 깨지는 것은 없다
|
|
4
|
+
|
|
5
|
+
0.4.0 은 **추가형**이다. 0.3.0 표면(`createCxAgent`·`session`·어댑터·클래스명)은
|
|
6
|
+
전부 그대로 동작한다 (기존 시험 107건 무수정 통과, 신규 10건).
|
|
7
|
+
|
|
8
|
+
## 새로 생긴 것 — 처리 액션 (선택)
|
|
9
|
+
|
|
10
|
+
- `createCxAgent({ actorAssertion })` — 고객사가 발행한 단명·목적 한정 행위자
|
|
11
|
+
토큰을 돌려주는 함수. **넘기지 않으면 아무것도 바뀌지 않는다** (표면 닫힘).
|
|
12
|
+
- `session.offers` / `session.attachActionUi(ui)` / `session.executeAction(offer)` /
|
|
13
|
+
`session.reconcileAction(requestId)` / `session.pendingActions()`
|
|
14
|
+
- `agent.actionsEnabled`
|
|
15
|
+
- 헤드리스: `actionOffersView` · `actionOffersTree` · `ACTION_CLS` (`view.js`)
|
|
16
|
+
- 로케일 키 `ui_action_*` 14종 (ko 정본, en/ja/zh-TW 미검수)
|
|
17
|
+
- 저장: `storage.actionsImpl` — 눌렀던 처리의 pending 기록(기본 localStorage,
|
|
18
|
+
종단 후 24h). 세션 영속(sessionStorage 30분)과 **수명이 다르다**.
|
|
19
|
+
|
|
20
|
+
## 행동 계약 (새 표면에만 해당)
|
|
21
|
+
|
|
22
|
+
- 실행 요청은 **재시도 0**. 네트워크 오류 → 상태 조회 1회 → 허브에 기록이
|
|
23
|
+
없을 때(404)만 같은 request_id 로 1회 재전송.
|
|
24
|
+
- `unknown`(실행 여부 미확인)은 재실행 버튼이 없다 — `reconcileAction`(조회)로만
|
|
25
|
+
수렴한다. 화면이 이 규칙을 어기면 이중 실행이 된다.
|
|
26
|
+
- 결과 `result` 는 서버 화이트리스트 키만 온다. GET 상태 조회에는 `result` 가 없다.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# 0.2.x → 0.3.0 마이그레이션
|
|
31
|
+
|
|
32
|
+
## 요약 — 깨지는 것은 없다
|
|
33
|
+
|
|
34
|
+
0.2.x 의 루트 표면(`createCxHook`)·어댑터(`/react` `/vue` `/element` …)·
|
|
35
|
+
클래스명(`fcx-*`)은 **전부 그대로 동작한다** (기존 시험 94건 무수정 통과).
|
|
36
|
+
0.3.0 은 그 아래 구조를 객체화하고 새 표면을 **추가**한 릴리스다.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
// 0.2.x — 그대로 유효
|
|
40
|
+
import { createCxHook } from "@fcg-labs/cx-agent-hook";
|
|
41
|
+
|
|
42
|
+
// 0.3.0 — 문의별 세션·초안 영속이 필요하면 이걸로 올라온다
|
|
43
|
+
import { createCxAgent } from "@fcg-labs/cx-agent-hook/agent";
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 왜 올라오나 — 0.2.x 훅의 구조적 한계 2가지
|
|
47
|
+
|
|
48
|
+
0.2.x 훅은 **채택 슬롯이 훅 전역 하나**이고 **초안이 화면(컴포넌트) 상태**다.
|
|
49
|
+
그 귀결로 스테이징 실사용에서 두 결함이 실증됐다 (2026-08-10):
|
|
50
|
+
|
|
51
|
+
1. 다른 메뉴를 다녀오면 AI 초안이 사라진다 (컴포넌트 언마운트 = 초안 소멸)
|
|
52
|
+
2. 스트림 도중 문의를 전환하면 이전 문의 텍스트가 새 에디터를 오염할 수 있다
|
|
53
|
+
|
|
54
|
+
0.3.0 세션은 **초안의 정본을 세션 버퍼**에 두고 UI 를 탈착식 sink 로 분리해
|
|
55
|
+
두 결함을 구조의 귀결로 없앤다.
|
|
56
|
+
|
|
57
|
+
## 새 표면 한 눈에
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
const agent = createCxAgent({
|
|
61
|
+
baseUrl, token, domain, // 전송 3요소 (0.2.x 와 동일)
|
|
62
|
+
source: "cms", // 인그레스 채널명 (테넌트 선언)
|
|
63
|
+
draftDecorators: [/* 기본: 한국어 호칭 개인화. 비한국어면 [] */],
|
|
64
|
+
storage: { ttlMs: 30 * 60_000, maxSessions: 20 }, // 초안 영속 정책
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const session = agent.session(csId); // 문의별 — 같은 id = 같은 세션
|
|
68
|
+
session.attachUi({ getDraft, setDraft, setStatus });
|
|
69
|
+
session.restore(); // 복귀 시 초안·채택 복원 한 줄
|
|
70
|
+
session.compose({ inquiry, context }); // 이탈해도 버퍼로 완주
|
|
71
|
+
session.remember(editorText); // 상담사 편집을 정본에 반영
|
|
72
|
+
session.answerSent(finalText, agentId); // gold 쌍 후킹 + 세션 소멸
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 훅 → 세션 대응표
|
|
76
|
+
|
|
77
|
+
| 0.2.x (`createCxHook`) | 0.3.0 (`agent.session(id)`) |
|
|
78
|
+
|-------------------------------|----------------------------------|
|
|
79
|
+
| `composeDraft({...sink})` | `attachUi(sink)` + `compose({})` |
|
|
80
|
+
| `noteAdopted / clearAdopted` | 동명 메서드 |
|
|
81
|
+
| `answerSent/scored/edited/discarded` | 동명 메서드 (consume 규칙 동일) |
|
|
82
|
+
| `adoptedAnswerId` | `session.adoptedAnswerId` |
|
|
83
|
+
| (없음 — 결함의 원천) | `detachUi()` · `restore()` · `remember()` |
|
|
84
|
+
| `requestAnswer / inquirySent / ready / flags / messages` | agent 동명 메서드 |
|
|
85
|
+
|
|
86
|
+
## 행동 변화 (호환층 아님 — 원장 어휘)
|
|
87
|
+
|
|
88
|
+
- 문의 전환 시 CMS 가 쏘던 `discarded(note:"inquiry_switch")` 는 세션
|
|
89
|
+
구조에서 **불필요해진다** (전환은 더 이상 오염이 아니다). 대신 수명 종료
|
|
90
|
+
어휘가 늘었다: `session_expired`(TTL 만료) · `session_evicted`(LRU 퇴거).
|
|
91
|
+
원장 통계를 사유별로 보던 곳은 note 어휘에 이 둘을 반영할 것.
|
|
92
|
+
- 초안이 sessionStorage 에 남는다 (탭 수명 + TTL, 발송·폐기 시 즉시 삭제).
|
|
93
|
+
브라우저 공용 PC 정책이 있으면 `storage: { enabled: false }`.
|
|
94
|
+
|
|
95
|
+
## CSS — 고객 제어권
|
|
96
|
+
|
|
97
|
+
토큰 선언이 `.fcx-ai` 에서 **사용처 `var(--fcx-*, 폴백)` 직참조**로 바뀌었다.
|
|
98
|
+
이제 소비처 전역 한 줄로 팔레트를 바꾼다:
|
|
99
|
+
|
|
100
|
+
```css
|
|
101
|
+
:root { --fcx-accent: #7c3aed; --fcx-radius: 6px; }
|
|
102
|
+
[data-fcx-theme="dark"] { /* 다크는 조상 속성으로 켠다 */ }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
0.2.x 처럼 `.fcx-ai { --fcx-accent: … }` 로 덮던 코드도 그대로 동작한다
|
|
106
|
+
(더 구체적인 선언이 이긴다). 클래스명 리네임은 없다.
|
|
107
|
+
|
|
108
|
+
headless 2단계: ① 스타일 0 — styles.css 를 import 하지 않는다.
|
|
109
|
+
② UI 0 — 어댑터 대신 `aiSuggestTree`(view.js)와 세션 표면만 쓴다.
|
|
110
|
+
|
|
111
|
+
## 기타
|
|
112
|
+
|
|
113
|
+
- `AnswerResult.answerId` 타입이 `number|string|null` — 신원 수술(ULID)
|
|
114
|
+
이후 허브 발급분은 문자열이다. 불투명 취급하면 코드 변경은 없다.
|
|
115
|
+
- element 어댑터의 내부 DOM 구조는 동일하나, 어댑터 3종이 `aiSuggestTree`
|
|
116
|
+
인터프리터로 재구현됐다 — 클래스·이벤트·마크업 계약은 시험으로 고정.
|
package/README.md
CHANGED
|
@@ -30,6 +30,61 @@ npm install @fcg-labs/cx-agent-hook
|
|
|
30
30
|
React·Vue 어댑터를 쓸 때만 그 프레임워크가 필요하다 (optional peer). 전송 계층만
|
|
31
31
|
쓰면 의존성은 0 이다.
|
|
32
32
|
|
|
33
|
+
## 0.3.0 — 문의별 세션 (권장 표면)
|
|
34
|
+
|
|
35
|
+
`createCxHook` 은 그대로 동작한다(호환층). 문의별 초안 상태·영속이 필요한
|
|
36
|
+
관리자 화면은 세션 표면으로 올라온다 — **초안의 정본이 세션 버퍼**라 메뉴
|
|
37
|
+
이탈·문의 전환에도 AI 초안이 살아남고, 스트림은 화면과 무관하게 완주한다:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
import { createCxAgent } from "@fcg-labs/cx-agent-hook/agent";
|
|
41
|
+
|
|
42
|
+
const agent = createCxAgent({ baseUrl, token, domain });
|
|
43
|
+
const session = agent.session(inquiryId); // 같은 id = 같은 세션
|
|
44
|
+
session.attachUi({ getDraft, setDraft, setStatus });
|
|
45
|
+
session.restore(); // 복귀 시 초안 복원 한 줄
|
|
46
|
+
session.compose({ inquiry, context }); // 이탈해도 버퍼로 완주
|
|
47
|
+
session.remember(editorText); // 상담사 편집 반영
|
|
48
|
+
session.answerSent(finalText, agentId); // 발송 후킹 + 세션 소멸
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
테넌트 특수분은 파라미터다: `source`(인그레스 채널명, 기본 "cms"),
|
|
52
|
+
`draftDecorators`(초안 후처리 — 기본 한국어 호칭 개인화, 비한국어는 `[]`),
|
|
53
|
+
`storage`(sessionStorage TTL·LRU 정책). 상세와 훅→세션 대응표는
|
|
54
|
+
[MIGRATION.md](./MIGRATION.md).
|
|
55
|
+
|
|
56
|
+
CSS 는 사용처 `var(--fcx-*, 폴백)` 직참조라 소비처 `:root` 한 줄로 팔레트를
|
|
57
|
+
바꾼다. 다크는 조상에 `data-fcx-theme="dark"`. headless 2단계(스타일 0 /
|
|
58
|
+
UI 0 — `aiSuggestTree`)도 공식 표면이다.
|
|
59
|
+
|
|
60
|
+
## 0.4.0 — 처리 액션 (말에서 행으로, 선택 표면)
|
|
61
|
+
|
|
62
|
+
답변 제안 옆에 **처리 선택지**가 실릴 수 있다 (`payload.suggested_actions`). 이 SDK 는
|
|
63
|
+
그 선택지를 보관·표시하고, 상담사 확인 뒤 허브에 실행을 요청하고, 결과 3상태를
|
|
64
|
+
보여준다. **실행기·권한·정책은 고객사 서버가 소유**한다 — SDK 는 계약(`actions/1`)의
|
|
65
|
+
클라이언트일 뿐이다.
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
const agent = createCxAgent({
|
|
69
|
+
baseUrl, token, domain,
|
|
70
|
+
// 고객사가 발행하는 단명·목적 한정 행위자 토큰. 없으면 액션 표면은 닫힌다
|
|
71
|
+
// (선택지는 오되 실행 버튼이 없다). 전권 세션 토큰을 넘기지 말 것.
|
|
72
|
+
actorAssertion: () => fetchMyActionToken(),
|
|
73
|
+
});
|
|
74
|
+
const session = agent.session(inquiryId);
|
|
75
|
+
session.attachActionUi({ setOffers: (offers, pending) => render(offers, pending) });
|
|
76
|
+
// 사람 확인(확인 문구·고위험은 2단) 뒤에만:
|
|
77
|
+
const r = await session.executeAction(offer, { actorClaimed: agentDisplayName });
|
|
78
|
+
// r.state: succeeded | failed | unknown — unknown 은 "다시 확인"(reconcile) 만, 재실행 없음
|
|
79
|
+
await session.reconcileAction(r.requestId);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
계약 요점: 요청은 request_id(ULID)로 멱등이고 SDK 는 **재시도하지 않는다** —
|
|
83
|
+
네트워크 오류면 상태를 묻고, 허브에 기록이 없을 때만 같은 request_id 로 1회
|
|
84
|
+
재전송한다. 눌렀던 처리는 `session.pendingActions()` 로 새로고침 뒤에도 안다
|
|
85
|
+
(정본은 허브 원장). 헤드리스 뷰: `actionOffersView` / `actionOffersTree`(`view.js`),
|
|
86
|
+
클래스 `fcx-act-*`, 문구는 `ui_action_*` 로케일 키.
|
|
87
|
+
|
|
33
88
|
## 지원 프레임워크
|
|
34
89
|
|
|
35
90
|
| 진입점 | 대상 | 필요한 peer |
|
package/agent.d.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.3.0 객체 표면 — CxAgent + InquirySession.
|
|
3
|
+
*
|
|
4
|
+
* 보편 구조(설정·전송·문구·채택 수명·세션·영속)는 코어가 소유하고, 테넌트
|
|
5
|
+
* 특수분(source·draftDecorators·storage)은 파라미터다. 전송 계층 타입은
|
|
6
|
+
* client.d.ts 에 있고 여기서 다시 내보내지 않는다.
|
|
7
|
+
*/
|
|
8
|
+
import type { AnswerResult, CxAgentConfig } from "./client.js";
|
|
9
|
+
import type { Locale } from "./index.js";
|
|
10
|
+
|
|
11
|
+
export type { AnswerResult };
|
|
12
|
+
export { LOCALES, MESSAGES, normalizeLocale } from "./index.js";
|
|
13
|
+
|
|
14
|
+
/** 초안 후처리 데코레이터 — 사슬로 적용된다 (예: 호칭 개인화) */
|
|
15
|
+
export type DraftDecorator = (
|
|
16
|
+
text: string,
|
|
17
|
+
ctx: { customerName?: string },
|
|
18
|
+
) => string;
|
|
19
|
+
|
|
20
|
+
/** 세션 영속 정책 (sessionStorage) */
|
|
21
|
+
export interface SessionStorageConfig {
|
|
22
|
+
/** false 면 영속 없이 메모리 세션만 (기본 true) */
|
|
23
|
+
enabled?: boolean;
|
|
24
|
+
/** 초안 보존 시한 — 기본 30분. 만료는 discarded(session_expired)로 원장에 남는다 */
|
|
25
|
+
ttlMs?: number;
|
|
26
|
+
/** 동시 보존 세션 수 — 기본 20. 초과는 LRU 퇴거(session_evicted) */
|
|
27
|
+
maxSessions?: number;
|
|
28
|
+
/** 저장 키 접두 — 기본 `cx-agent-session:<domain>` */
|
|
29
|
+
prefix?: string;
|
|
30
|
+
/** 저장소 주입 (시험·커스텀) — 기본 sessionStorage */
|
|
31
|
+
impl?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CxAgentSetup = Partial<CxAgentConfig> & {
|
|
35
|
+
locale?: string;
|
|
36
|
+
bootstrap?: boolean;
|
|
37
|
+
messages?: Record<string, string>;
|
|
38
|
+
/** 인그레스 채널명 (테넌트 선언) — 기본 "cms" */
|
|
39
|
+
source?: string;
|
|
40
|
+
/** 초안 후처리 사슬 — 기본 [koreanGreetingDecorator]. 비한국어 테넌트는 [] */
|
|
41
|
+
draftDecorators?: DraftDecorator[];
|
|
42
|
+
storage?: SessionStorageConfig & {
|
|
43
|
+
/** 처리 액션 pending 저장소 구현 (기본 localStorage) — 세션 영속과 수명이 다르다 */
|
|
44
|
+
actionsImpl?: Storage;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* 처리 액션 (actions/1) 행위자 자격증명 — 고객사가 발행한 **단명·목적 한정**
|
|
48
|
+
* 토큰(aud=cx-action, 분 단위 ttl)을 돌려주는 함수. 없으면 액션 표면은 닫힌다
|
|
49
|
+
* (선택지는 오되 실행 버튼이 없다). 전권 세션 토큰을 넘기는 것은 계약 위반.
|
|
50
|
+
*/
|
|
51
|
+
actorAssertion?: () => string | Promise<string>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface ComposeHandle {
|
|
55
|
+
promise: Promise<AnswerResult>;
|
|
56
|
+
abort: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SessionUi {
|
|
60
|
+
getDraft?: () => string;
|
|
61
|
+
setDraft: (text: string) => void;
|
|
62
|
+
setStatus?: (text: string) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 문의 1건의 AI 초안 수명주기 — 초안의 정본은 세션 버퍼, UI 는 sink 다 */
|
|
66
|
+
export declare class InquirySession {
|
|
67
|
+
readonly externalId: string;
|
|
68
|
+
readonly state: "idle" | "composing" | "drafted" | "declined";
|
|
69
|
+
readonly draft: string;
|
|
70
|
+
readonly adoptedAnswerId: number | string | null;
|
|
71
|
+
|
|
72
|
+
/** UI sink 연결 — 자동 주입 없음, 복원은 restore() 명시 호출 */
|
|
73
|
+
attachUi(ui: SessionUi): this;
|
|
74
|
+
/** UI 분리 — 진행 중 스트림은 버퍼로 계속 완주한다 */
|
|
75
|
+
detachUi(): this;
|
|
76
|
+
/** 세션 정본을 UI 로 (복귀 한 줄) */
|
|
77
|
+
restore(): { draft: string; answerId: number | string | null; state: string };
|
|
78
|
+
/** 상담사 편집을 정본에 반영 (스로틀은 소비처 몫) */
|
|
79
|
+
remember(text: string): void;
|
|
80
|
+
|
|
81
|
+
compose(opts: {
|
|
82
|
+
inquiry: string;
|
|
83
|
+
context?: Record<string, string>;
|
|
84
|
+
customerName?: string;
|
|
85
|
+
confirmOverwrite?: () => boolean;
|
|
86
|
+
/** 스트림 시한 관통 — 큰 컨텍스트 창(프리필 분 단위) 운영용. 기본 90s/190s */
|
|
87
|
+
idleTimeoutMs?: number;
|
|
88
|
+
overallTimeoutMs?: number;
|
|
89
|
+
}): ComposeHandle;
|
|
90
|
+
|
|
91
|
+
noteAdopted(answerId: number | string | null): void;
|
|
92
|
+
clearAdopted(): void;
|
|
93
|
+
answerSent(finalText: string, agent?: string): void;
|
|
94
|
+
scored(score: number, agent?: string): void;
|
|
95
|
+
edited(finalText: string, agent?: string): void;
|
|
96
|
+
discarded(agent?: string, note?: string): void;
|
|
97
|
+
|
|
98
|
+
// ── 처리 액션 (actions/1) ──
|
|
99
|
+
/** 마지막 답변의 처리 선택지 (만료 제외). 실행 시맨틱 없음 — 사람 확인 후 executeAction */
|
|
100
|
+
readonly offers: ActionOffer[];
|
|
101
|
+
/** 선택지 UI sink — setOffers(offers, pending) 를 받는다 */
|
|
102
|
+
attachActionUi(ui: { setOffers: (offers: ActionOffer[], pending: PendingAction[]) => void }): void;
|
|
103
|
+
detachActionUi(): void;
|
|
104
|
+
/** 이 문의에서 눌렀던 처리들 (정본은 허브 원장 — 브라우저 소실은 표시 손실뿐) */
|
|
105
|
+
pendingActions(): PendingAction[];
|
|
106
|
+
/** 실행 — 확인은 호출자가 끝낸 뒤. request_id 발급·전송 전 영속·재시도 0 */
|
|
107
|
+
executeAction(offer: ActionOffer, opts?: { actorClaimed?: string }): Promise<ActionResult & { requestId: string }>;
|
|
108
|
+
/** 재조정 — lookup 만 (재실행 없음). unknown 만 의미 있다 */
|
|
109
|
+
reconcileAction(requestId: string): Promise<{ ok: boolean; state: string; reasonCode?: string; message?: string; error: string | null }>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 답변 payload 의 suggested_actions 항목 (허브 평탄화 뷰 — endpoint·트리거·승인 시각 없음) */
|
|
113
|
+
export interface ActionOffer {
|
|
114
|
+
offer_id: string;
|
|
115
|
+
event_id: string;
|
|
116
|
+
action_key: string;
|
|
117
|
+
params_bound: Record<string, string>;
|
|
118
|
+
params_hash: string;
|
|
119
|
+
params_display: string;
|
|
120
|
+
label: string;
|
|
121
|
+
description: string;
|
|
122
|
+
confirm: string;
|
|
123
|
+
risk_level: "low" | "medium" | "high";
|
|
124
|
+
expires_at: string;
|
|
125
|
+
catalog_version: number;
|
|
126
|
+
offer_reason: "issue_key" | "context" | "tokens";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface PendingAction {
|
|
130
|
+
requestId: string;
|
|
131
|
+
offerId: string;
|
|
132
|
+
actionKey: string;
|
|
133
|
+
label: string;
|
|
134
|
+
state: "pending" | "succeeded" | "failed" | "unknown" | string;
|
|
135
|
+
reasonCode?: string;
|
|
136
|
+
message?: string;
|
|
137
|
+
at: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ActionResult {
|
|
141
|
+
ok: boolean;
|
|
142
|
+
/** succeeded | failed | unknown | in_flight | '' */
|
|
143
|
+
state: string;
|
|
144
|
+
reasonCode: string;
|
|
145
|
+
message: string;
|
|
146
|
+
actorAttested: string;
|
|
147
|
+
auditRef: string;
|
|
148
|
+
result: Record<string, unknown> | null;
|
|
149
|
+
deduplicated: boolean;
|
|
150
|
+
/** offer_locked | request_conflict | actor_assertion_required | actions_disabled | network_error | … */
|
|
151
|
+
error: string | null;
|
|
152
|
+
httpStatus: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface CxAgent {
|
|
156
|
+
enabled: boolean;
|
|
157
|
+
ready: Promise<void>;
|
|
158
|
+
readonly flags: Record<string, unknown>;
|
|
159
|
+
locale: Locale;
|
|
160
|
+
messages: Record<string, string>;
|
|
161
|
+
declineText(reason: string): string;
|
|
162
|
+
/** 원격 설정(bootstrap) 반영 구독 — 반환값으로 해지 */
|
|
163
|
+
onConfigChange(
|
|
164
|
+
fn: (cfg: { messages: Record<string, string>; flags: Record<string, unknown> }) => void,
|
|
165
|
+
): () => void;
|
|
166
|
+
|
|
167
|
+
/** 문의별 세션 — 같은 externalId 는 같은 인스턴스. 인자 없으면 비영속 일회 세션 */
|
|
168
|
+
session(externalId?: string | number, opts?: { persist?: boolean }): InquirySession;
|
|
169
|
+
|
|
170
|
+
requestAnswer(inquiry: string, context?: Record<string, string>): Promise<AnswerResult>;
|
|
171
|
+
requestInvestigation(
|
|
172
|
+
externalId: string | number,
|
|
173
|
+
identity?: { userId?: string | number; subUserId?: string | number },
|
|
174
|
+
): Promise<{ ok: boolean; status: string; error: string | null }>;
|
|
175
|
+
investigationStatus(
|
|
176
|
+
externalId: string | number,
|
|
177
|
+
): Promise<{ ok: boolean; status: string; jobId?: number; error: string | null }>;
|
|
178
|
+
/** 처리 액션 표면 열림 여부 — 전송 3요소 + actorAssertion */
|
|
179
|
+
readonly actionsEnabled: boolean;
|
|
180
|
+
inquirySent(payload: {
|
|
181
|
+
externalId: string | number;
|
|
182
|
+
inquiry: string;
|
|
183
|
+
reply?: string;
|
|
184
|
+
agent?: string;
|
|
185
|
+
meta?: Record<string, string>;
|
|
186
|
+
}): void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export declare function createCxAgent(config?: CxAgentSetup): CxAgent;
|
|
190
|
+
/** 기본 초안 데코레이터 — 한국어 인사말 호칭 개인화 (서버 선굽기의 안전망) */
|
|
191
|
+
export declare const koreanGreetingDecorator: DraftDecorator;
|
|
192
|
+
export declare function notConfiguredResult(): AnswerResult;
|
|
193
|
+
export declare function textOf(messages: Record<string, string>, reason: string): string;
|
|
194
|
+
|
|
195
|
+
// 헤드리스 액션 뷰 재수출 (view.d.ts 정본)
|
|
196
|
+
export { ACTION_CLS, actionOffersTree, actionOffersView } from "./view.js";
|
|
197
|
+
export type { ActionOfferItemView } from "./view.js";
|
package/agent.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CxAgent — 0.3.0 객체 표면. `createCxAgent(config)` 하나로 테넌트를 세운다.
|
|
3
|
+
*
|
|
4
|
+
* 구조 원칙 (객체화의 이유):
|
|
5
|
+
* - **보편 구조는 코어가 소유한다** — 어느 CMS 든 존재하는 것들: 설정
|
|
6
|
+
* 동결, 전송, 로케일/문구, 채택 수명, 문의별 세션, 초안 영속.
|
|
7
|
+
* - **테넌트 특수분은 파라미터다** — `source`(인그레스 채널명),
|
|
8
|
+
* `draftDecorators`(호칭 개인화 등 초안 후처리), `storage`(영속 정책).
|
|
9
|
+
* 특정 테넌트 전용 값이 코어에 하드코딩되던 것(source:"cms", 한국어
|
|
10
|
+
* 인사말 정규식)을 기본값 있는 파라미터로 끌어냈다.
|
|
11
|
+
* - 전송 계층(client.js)은 여전히 비공개 — 능력이 모자라면 이 표면을
|
|
12
|
+
* 채운다 (0.2.x 원칙 승계).
|
|
13
|
+
*
|
|
14
|
+
* 0.2.x `createCxHook` 은 이 위의 호환 표면이다 (compat.js) — 루트 import
|
|
15
|
+
* 는 그대로 동작한다. 새 소비처는 이 파일의 표면으로 온다:
|
|
16
|
+
*
|
|
17
|
+
* ```js
|
|
18
|
+
* import { createCxAgent } from "@fcg-labs/cx-agent-hook/agent";
|
|
19
|
+
* const agent = createCxAgent({ baseUrl, token, domain });
|
|
20
|
+
* const session = agent.session(csId); // 문의별 상태·초안 영속
|
|
21
|
+
* session.attachUi({ getDraft, setDraft, setStatus }).restore();
|
|
22
|
+
* session.compose({ inquiry, context }); // 이탈해도 완주, 복귀 시 restore
|
|
23
|
+
* session.answerSent(finalText, agentId); // gold 쌍 후킹 + 세션 소멸
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
import { CxAgentClient, newRequestId } from "./client.js";
|
|
27
|
+
import { LOCALES, MESSAGES, normalizeLocale, resolveMessages } from "./locales.js";
|
|
28
|
+
import { ActionLedger, InquirySession, SessionStore } from "./session.js";
|
|
29
|
+
import { ACTION_CLS, actionOffersTree, actionOffersView } from "./view.js";
|
|
30
|
+
|
|
31
|
+
// 헤드리스 액션 뷰 — 프레임워크 어댑터(react/vue) 슬롯은 0.4.x 후속. 그전엔
|
|
32
|
+
// 소비처가 이 순수 함수로 자기 렌더러에 그린다 (텍스트 노드만 — innerHTML 금지).
|
|
33
|
+
export { ACTION_CLS, actionOffersTree, actionOffersView };
|
|
34
|
+
|
|
35
|
+
export { LOCALES, MESSAGES, normalizeLocale };
|
|
36
|
+
|
|
37
|
+
/** 문구 한 벌에서 사유 하나 — 기계 코드를 화면에 노출하지 않는다. */
|
|
38
|
+
export function textOf(messages, reason) {
|
|
39
|
+
const known = messages[reason];
|
|
40
|
+
if (known) return known;
|
|
41
|
+
if (String(reason || "").startsWith("http_")) return messages.http_error;
|
|
42
|
+
return messages.unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 미설정 결과 팩토리 — 0.2.x 의 가변 싱글턴(공유 객체 오염 위험) 청산. */
|
|
46
|
+
export function notConfiguredResult() {
|
|
47
|
+
return {
|
|
48
|
+
ok: false, answered: false, answer: "", answerId: null,
|
|
49
|
+
evidence: [], declinedReason: "not_configured", raw: {},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 기본 초안 데코레이터 — 한국어 인사말 호칭 개인화.
|
|
55
|
+
*
|
|
56
|
+
* 서버가 사설 서빙(AI_CS_LLM_PRIVATE)이면 인사말을 처음부터 이름으로 굽고,
|
|
57
|
+
* 이 치환은 안전망이다: 이름 없이 "안녕하세요 고객님" 으로 온 초안에만
|
|
58
|
+
* 작동하고, 서버가 이미 이름을 넣었으면 패턴이 안 맞아 no-op.
|
|
59
|
+
* 한국어 CS 가 아닌 테넌트는 `draftDecorators: []` 로 끈다.
|
|
60
|
+
*/
|
|
61
|
+
export function koreanGreetingDecorator(text, { customerName } = {}) {
|
|
62
|
+
const name = String(customerName || "").trim();
|
|
63
|
+
if (!name) return text;
|
|
64
|
+
return String(text).replace(
|
|
65
|
+
/안녕하세요[,]?\s*고객님/,
|
|
66
|
+
(m) => m.replace("고객님", `${name} 고객님`),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* CxAgent 를 만든다 — 미설정이면 전부 무동작 (자격 없이는 네트워크 0).
|
|
72
|
+
*
|
|
73
|
+
* @param {object} config
|
|
74
|
+
* @param {string} config.baseUrl · config.token · config.domain 전송 3요소
|
|
75
|
+
* @param {"hub"|"platform"} [config.api="hub"] 기본 대상 — **agent 가 기본값의
|
|
76
|
+
* 유일한 소유자다** (0.2.x 에서 훅 "hub" vs 클라이언트 "platform" 으로
|
|
77
|
+
* 갈라져 있던 불일치 청산. 클라이언트에는 항상 명시 전달한다).
|
|
78
|
+
* @param {string} [config.locale] · @param {object} [config.messages]
|
|
79
|
+
* @param {boolean} [config.bootstrap=false] 허브 client-config 병합
|
|
80
|
+
* @param {string} [config.source="cms"] 인그레스 채널명 (테넌트 선언)
|
|
81
|
+
* @param {Array<(text:string, ctx:{customerName?:string})=>string>}
|
|
82
|
+
* [config.draftDecorators] 초안 후처리 사슬 — 기본 [한국어 호칭]
|
|
83
|
+
* @param {{enabled?:boolean, ttlMs?:number, maxSessions?:number,
|
|
84
|
+
* prefix?:string, impl?:Storage}} [config.storage] 세션 영속 정책
|
|
85
|
+
*/
|
|
86
|
+
export function createCxAgent(config = {}) {
|
|
87
|
+
const {
|
|
88
|
+
baseUrl, token, domain, api = "hub", onError,
|
|
89
|
+
locale, messages: messageOverrides,
|
|
90
|
+
bootstrap = false,
|
|
91
|
+
source = "cms",
|
|
92
|
+
draftDecorators = [koreanGreetingDecorator],
|
|
93
|
+
storage = {},
|
|
94
|
+
// 처리 액션 (actions/1): 고객사가 발행한 단명·목적 한정 행위자 토큰을 돌려주는
|
|
95
|
+
// 함수. 없으면 액션 표면은 닫힌다 (offers 는 오되 실행 버튼은 없다).
|
|
96
|
+
actorAssertion,
|
|
97
|
+
...rest
|
|
98
|
+
} = config;
|
|
99
|
+
|
|
100
|
+
const messages = resolveMessages(locale, messageOverrides);
|
|
101
|
+
let flags = {};
|
|
102
|
+
const configListeners = new Set();
|
|
103
|
+
const client =
|
|
104
|
+
baseUrl && token && domain
|
|
105
|
+
? new CxAgentClient({
|
|
106
|
+
baseUrl, token, domain, api, source,
|
|
107
|
+
// 후킹 실패는 CS 업무와 무관 — 기록만 하고 화면을 막지 않는다.
|
|
108
|
+
onError:
|
|
109
|
+
onError ||
|
|
110
|
+
((err, ctx) => console.warn("[cx-agent-hook]", ctx.op, err.message)),
|
|
111
|
+
...rest,
|
|
112
|
+
})
|
|
113
|
+
: null;
|
|
114
|
+
|
|
115
|
+
const decorate = (text, ctx) =>
|
|
116
|
+
draftDecorators.reduce((acc, fn) => {
|
|
117
|
+
try { return fn(acc, ctx || {}); } catch { return acc; }
|
|
118
|
+
}, String(text ?? ""));
|
|
119
|
+
|
|
120
|
+
const applyRemoteConfig = (remote) => {
|
|
121
|
+
if (!remote || typeof remote !== "object") return;
|
|
122
|
+
if (remote.locale || remote.messages) {
|
|
123
|
+
// 원격 문구 위에 로컬 override 재적용 — 로컬 우선 규칙을 병합 순서로 강제
|
|
124
|
+
const next = resolveMessages(locale || remote.locale, {
|
|
125
|
+
...(remote.messages || {}),
|
|
126
|
+
...(messageOverrides || {}),
|
|
127
|
+
});
|
|
128
|
+
Object.assign(messages, next);
|
|
129
|
+
}
|
|
130
|
+
if (remote.flags && typeof remote.flags === "object") {
|
|
131
|
+
flags = { ...remote.flags };
|
|
132
|
+
}
|
|
133
|
+
for (const fn of configListeners) {
|
|
134
|
+
try { fn({ messages, flags }); } catch { /* 리스너 실패 무시 */ }
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const fetchRemoteConfig = async () => {
|
|
139
|
+
const doFetch = rest.fetchImpl || (typeof fetch !== "undefined" ? fetch : null);
|
|
140
|
+
if (!doFetch) return;
|
|
141
|
+
const cacheKey = `cx-agent-config:${domain}`;
|
|
142
|
+
try {
|
|
143
|
+
if (typeof sessionStorage !== "undefined") {
|
|
144
|
+
const cached = JSON.parse(sessionStorage.getItem(cacheKey) || "null");
|
|
145
|
+
if (cached && Date.now() - cached.at < 5 * 60 * 1000) {
|
|
146
|
+
applyRemoteConfig(cached.config);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch { /* 캐시 실패는 무시 — 네트워크로 진행 */ }
|
|
151
|
+
const res = await doFetch(
|
|
152
|
+
`${String(baseUrl).replace(/\/$/, "")}/v1/domains/${domain}/client-config`,
|
|
153
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
154
|
+
);
|
|
155
|
+
if (!res.ok) return; // 404(미발행) 포함 — 정적 설정으로 동작 (fail-soft)
|
|
156
|
+
const remote = await res.json();
|
|
157
|
+
applyRemoteConfig(remote);
|
|
158
|
+
try {
|
|
159
|
+
if (typeof sessionStorage !== "undefined") {
|
|
160
|
+
sessionStorage.setItem(
|
|
161
|
+
cacheKey, JSON.stringify({ at: Date.now(), config: remote }),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
} catch { /* 저장 실패 무시 */ }
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// 부트스트랩은 백그라운드 — 화면을 막지 않는다. 실패도 resolve.
|
|
168
|
+
const ready = (bootstrap && client && api === "hub")
|
|
169
|
+
? fetchRemoteConfig().catch(() => {})
|
|
170
|
+
: Promise.resolve();
|
|
171
|
+
|
|
172
|
+
// 세션 영속 — 퇴거(TTL·LRU)는 원장에 기각으로 남긴다 (침묵 소거 금지)
|
|
173
|
+
const store = storage.enabled === false || !client
|
|
174
|
+
? null
|
|
175
|
+
: new SessionStore({
|
|
176
|
+
impl: storage.impl,
|
|
177
|
+
prefix: storage.prefix || `cx-agent-session:${domain}`,
|
|
178
|
+
ttlMs: storage.ttlMs,
|
|
179
|
+
maxSessions: storage.maxSessions,
|
|
180
|
+
onEvict: (rec, why) => {
|
|
181
|
+
if (rec && rec.answerId) client.discarded(rec.answerId, "", why);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const actionLedger = storage.enabled === false || !client
|
|
186
|
+
? null
|
|
187
|
+
: new ActionLedger({ impl: storage.actionsImpl, prefix: `cx-agent-actions:${domain}` });
|
|
188
|
+
const sessionDeps = {
|
|
189
|
+
client, messages, decorate, store,
|
|
190
|
+
textOf: (reason) => textOf(messages, reason),
|
|
191
|
+
actorAssertion: typeof actorAssertion === "function" ? actorAssertion : null,
|
|
192
|
+
actionLedger, newRequestId,
|
|
193
|
+
};
|
|
194
|
+
const sessions = new Map();
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
/** 전송 3요소가 다 있으면 true */
|
|
198
|
+
enabled: Boolean(client),
|
|
199
|
+
/** 부트스트랩 완료 신호 — bootstrap 미사용 시 즉시 resolve */
|
|
200
|
+
ready,
|
|
201
|
+
/** 중앙 발행 기능 플래그 — 부트스트랩 전/미발행이면 빈 객체 */
|
|
202
|
+
get flags() { return flags; },
|
|
203
|
+
/** setup 에서 정해진 화면 언어 (정규화) */
|
|
204
|
+
locale: normalizeLocale(locale),
|
|
205
|
+
/** 이 테넌트의 문구 한 벌 — 부트스트랩이 제자리 갱신한다 */
|
|
206
|
+
messages,
|
|
207
|
+
declineText(reason) { return textOf(messages, reason); },
|
|
208
|
+
/** 원격 설정 반영 구독 (bootstrap) — 반환값으로 해지 */
|
|
209
|
+
onConfigChange(fn) {
|
|
210
|
+
configListeners.add(fn);
|
|
211
|
+
return () => configListeners.delete(fn);
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 문의별 세션 — 같은 externalId 는 같은 인스턴스 (프로세스 내 캐시 +
|
|
216
|
+
* sessionStorage 복원). externalId 없이 부르면 비영속 일회 세션.
|
|
217
|
+
*/
|
|
218
|
+
session(externalId, opts = {}) {
|
|
219
|
+
const id = externalId == null ? "" : String(externalId);
|
|
220
|
+
if (!id) return new InquirySession("", { ...sessionDeps, store: null });
|
|
221
|
+
let s = sessions.get(id);
|
|
222
|
+
if (!s) {
|
|
223
|
+
s = new InquirySession(id, {
|
|
224
|
+
...sessionDeps,
|
|
225
|
+
store: opts.persist === false ? null : store,
|
|
226
|
+
});
|
|
227
|
+
sessions.set(id, s);
|
|
228
|
+
}
|
|
229
|
+
return s;
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/** 답변 제안 (비스트림) — throw 하지 않음 */
|
|
233
|
+
requestAnswer(inquiry, context) {
|
|
234
|
+
if (!client || !inquiry) return Promise.resolve(notConfiguredResult());
|
|
235
|
+
return client.getAnswer(inquiry, context);
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
/** 조사 요청 (E-8) — 큐 적재만, 결과는 investigationStatus 폴링 */
|
|
239
|
+
requestInvestigation(externalId, identity) {
|
|
240
|
+
if (!client) return Promise.resolve({ ok: false, status: "", error: "not_configured" });
|
|
241
|
+
return client.requestInvestigation(externalId, identity);
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
investigationStatus(externalId) {
|
|
245
|
+
if (!client) return Promise.resolve({ ok: false, status: "", error: "not_configured" });
|
|
246
|
+
return client.investigationStatus(externalId);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
/** 처리 액션 표면이 열려 있는가 — 전송 3요소 + actorAssertion 함수 */
|
|
250
|
+
get actionsEnabled() { return Boolean(client && sessionDeps.actorAssertion); },
|
|
251
|
+
|
|
252
|
+
/** 문의+최종답변 쌍 적재 (fire-and-forget, external_id 멱등) */
|
|
253
|
+
inquirySent({ externalId, inquiry, reply, agent, meta } = {}) {
|
|
254
|
+
if (!client || !externalId || !inquiry) return;
|
|
255
|
+
client.logInquiry({ externalId, inquiry, reply, agent, meta });
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
package/client.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface CxAgentConfig {
|
|
|
25
25
|
feedback?: CapabilityTarget;
|
|
26
26
|
retries?: number;
|
|
27
27
|
timeoutMs?: number;
|
|
28
|
+
/** 인그레스 body.source (테넌트 채널명) — 기본 "cms" (0.3.0 파라미터화) */
|
|
29
|
+
source?: string;
|
|
28
30
|
onError?: (err: Error, context: { op: string; code?: string }) => void;
|
|
29
31
|
fetchImpl?: typeof fetch;
|
|
30
32
|
}
|
|
@@ -33,7 +35,8 @@ export interface AnswerResult {
|
|
|
33
35
|
ok: boolean;
|
|
34
36
|
answered: boolean;
|
|
35
37
|
answer: string;
|
|
36
|
-
|
|
38
|
+
/** 신원 수술(0048) 이후 허브 발급분은 ULID 문자열 — 불투명 취급 */
|
|
39
|
+
answerId: number | string | null;
|
|
37
40
|
evidence: Array<{ unit_key: string; title: string; score: number }>;
|
|
38
41
|
declinedReason: string;
|
|
39
42
|
raw: Record<string, unknown>;
|