@fcg-labs/cx-agent-hook 0.3.0 → 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 CHANGED
@@ -1,3 +1,32 @@
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
+
1
30
  # 0.2.x → 0.3.0 마이그레이션
2
31
 
3
32
  ## 요약 — 깨지는 것은 없다
package/README.md CHANGED
@@ -57,6 +57,34 @@ CSS 는 사용처 `var(--fcx-*, 폴백)` 직참조라 소비처 `:root` 한 줄
57
57
  바꾼다. 다크는 조상에 `data-fcx-theme="dark"`. headless 2단계(스타일 0 /
58
58
  UI 0 — `aiSuggestTree`)도 공식 표면이다.
59
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
+
60
88
  ## 지원 프레임워크
61
89
 
62
90
  | 진입점 | 대상 | 필요한 peer |
package/agent.d.ts CHANGED
@@ -39,7 +39,16 @@ export type CxAgentSetup = Partial<CxAgentConfig> & {
39
39
  source?: string;
40
40
  /** 초안 후처리 사슬 — 기본 [koreanGreetingDecorator]. 비한국어 테넌트는 [] */
41
41
  draftDecorators?: DraftDecorator[];
42
- storage?: SessionStorageConfig;
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>;
43
52
  };
44
53
 
45
54
  export interface ComposeHandle {
@@ -85,6 +94,62 @@ export declare class InquirySession {
85
94
  scored(score: number, agent?: string): void;
86
95
  edited(finalText: string, agent?: string): void;
87
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;
88
153
  }
89
154
 
90
155
  export interface CxAgent {
@@ -110,6 +175,8 @@ export interface CxAgent {
110
175
  investigationStatus(
111
176
  externalId: string | number,
112
177
  ): Promise<{ ok: boolean; status: string; jobId?: number; error: string | null }>;
178
+ /** 처리 액션 표면 열림 여부 — 전송 3요소 + actorAssertion */
179
+ readonly actionsEnabled: boolean;
113
180
  inquirySent(payload: {
114
181
  externalId: string | number;
115
182
  inquiry: string;
@@ -124,3 +191,7 @@ export declare function createCxAgent(config?: CxAgentSetup): CxAgent;
124
191
  export declare const koreanGreetingDecorator: DraftDecorator;
125
192
  export declare function notConfiguredResult(): AnswerResult;
126
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 CHANGED
@@ -23,9 +23,14 @@
23
23
  * session.answerSent(finalText, agentId); // gold 쌍 후킹 + 세션 소멸
24
24
  * ```
25
25
  */
26
- import { CxAgentClient } from "./client.js";
26
+ import { CxAgentClient, newRequestId } from "./client.js";
27
27
  import { LOCALES, MESSAGES, normalizeLocale, resolveMessages } from "./locales.js";
28
- import { InquirySession, SessionStore } from "./session.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 };
29
34
 
30
35
  export { LOCALES, MESSAGES, normalizeLocale };
31
36
 
@@ -86,6 +91,9 @@ export function createCxAgent(config = {}) {
86
91
  source = "cms",
87
92
  draftDecorators = [koreanGreetingDecorator],
88
93
  storage = {},
94
+ // 처리 액션 (actions/1): 고객사가 발행한 단명·목적 한정 행위자 토큰을 돌려주는
95
+ // 함수. 없으면 액션 표면은 닫힌다 (offers 는 오되 실행 버튼은 없다).
96
+ actorAssertion,
89
97
  ...rest
90
98
  } = config;
91
99
 
@@ -174,9 +182,14 @@ export function createCxAgent(config = {}) {
174
182
  },
175
183
  });
176
184
 
185
+ const actionLedger = storage.enabled === false || !client
186
+ ? null
187
+ : new ActionLedger({ impl: storage.actionsImpl, prefix: `cx-agent-actions:${domain}` });
177
188
  const sessionDeps = {
178
189
  client, messages, decorate, store,
179
190
  textOf: (reason) => textOf(messages, reason),
191
+ actorAssertion: typeof actorAssertion === "function" ? actorAssertion : null,
192
+ actionLedger, newRequestId,
180
193
  };
181
194
  const sessions = new Map();
182
195
 
@@ -233,6 +246,9 @@ export function createCxAgent(config = {}) {
233
246
  return client.investigationStatus(externalId);
234
247
  },
235
248
 
249
+ /** 처리 액션 표면이 열려 있는가 — 전송 3요소 + actorAssertion 함수 */
250
+ get actionsEnabled() { return Boolean(client && sessionDeps.actorAssertion); },
251
+
236
252
  /** 문의+최종답변 쌍 적재 (fire-and-forget, external_id 멱등) */
237
253
  inquirySent({ externalId, inquiry, reply, agent, meta } = {}) {
238
254
  if (!client || !externalId || !inquiry) return;
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
  };
@@ -591,6 +593,123 @@ export class CxAgentClient {
591
593
  clearTimeout(timer);
592
594
  }
593
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("");
594
713
  }
595
714
 
596
715
  export default CxAgentClient;
package/locales.js CHANGED
@@ -31,6 +31,20 @@ const en = {
31
31
  ui_adopt: "Insert into editor",
32
32
  ui_evidence: "Sources",
33
33
  ui_correcting: "Fixing phrasing — rewriting…",
34
+ ui_action_execute: "Process",
35
+ ui_action_confirm_default: "Run this action?",
36
+ ui_action_confirm_ok: "Yes, run",
37
+ ui_action_confirm_cancel: "Cancel",
38
+ ui_action_ack: "I understand this cannot be undone",
39
+ ui_action_running: "Processing…",
40
+ ui_action_succeeded: "Done",
41
+ ui_action_failed: "Not applied",
42
+ ui_action_unknown: "Could not confirm whether this was applied — check again before retrying.",
43
+ ui_action_reconcile: "Check again",
44
+ ui_action_disabled: "Actions are not enabled for this seat.",
45
+ ui_action_risk_low: "low risk",
46
+ ui_action_risk_medium: "medium risk",
47
+ ui_action_risk_high: "high risk",
34
48
  ui_overwrite_confirm: "Replace your current draft with the AI draft?",
35
49
  // 거절·오류 사유
36
50
  not_configured: "AI reply suggestions are not connected yet.",
@@ -57,6 +71,20 @@ const ko = {
57
71
  ui_adopt: "에디터에 넣기",
58
72
  ui_evidence: "근거",
59
73
  ui_correcting: "표현 교정 중 — 다시 쓰는 중...",
74
+ ui_action_execute: "처리하기",
75
+ ui_action_confirm_default: "이 처리를 진행할까요?",
76
+ ui_action_confirm_ok: "네, 진행",
77
+ ui_action_confirm_cancel: "취소",
78
+ ui_action_ack: "되돌릴 수 없음을 확인했습니다",
79
+ ui_action_running: "처리 중…",
80
+ ui_action_succeeded: "처리 완료",
81
+ ui_action_failed: "처리되지 않음",
82
+ ui_action_unknown: "실행 여부를 확인하지 못했습니다 — 다시 누르지 말고 먼저 확인하세요.",
83
+ ui_action_reconcile: "다시 확인",
84
+ ui_action_disabled: "이 계정에는 처리 기능이 켜져 있지 않습니다.",
85
+ ui_action_risk_low: "위험 낮음",
86
+ ui_action_risk_medium: "위험 보통",
87
+ ui_action_risk_high: "위험 높음",
60
88
  ui_overwrite_confirm: "작성 중인 답변을 지우고 AI 초안으로 바꿀까요?",
61
89
  not_configured: "AI 답변 제안이 아직 연결되지 않았습니다.",
62
90
  unsupported_api: "AI 답변 제안이 아직 연결되지 않았습니다.",
@@ -78,6 +106,20 @@ const ja = {
78
106
  ui_adopt: "エディタに挿入",
79
107
  ui_evidence: "根拠",
80
108
  ui_correcting: "表現を修正中 — 書き直しています…",
109
+ ui_action_execute: "処理する",
110
+ ui_action_confirm_default: "この処理を実行しますか?",
111
+ ui_action_confirm_ok: "はい、実行",
112
+ ui_action_confirm_cancel: "キャンセル",
113
+ ui_action_ack: "元に戻せないことを確認しました",
114
+ ui_action_running: "処理中…",
115
+ ui_action_succeeded: "処理完了",
116
+ ui_action_failed: "未処理",
117
+ ui_action_unknown: "実行の有無を確認できませんでした — 再度押さずに先に確認してください。",
118
+ ui_action_reconcile: "再確認",
119
+ ui_action_disabled: "このアカウントでは処理機能が有効になっていません。",
120
+ ui_action_risk_low: "リスク低",
121
+ ui_action_risk_medium: "リスク中",
122
+ ui_action_risk_high: "リスク高",
81
123
  ui_overwrite_confirm: "作成中の回答を消してAI下書きに置き換えますか?",
82
124
  not_configured: "AI 返信案はまだ接続されていません。",
83
125
  unsupported_api: "AI 返信案はまだ接続されていません。",
@@ -99,6 +141,20 @@ const zhTW = {
99
141
  ui_adopt: "插入編輯器",
100
142
  ui_evidence: "依據",
101
143
  ui_correcting: "正在修正表述 — 重新撰寫中…",
144
+ ui_action_execute: "處理",
145
+ ui_action_confirm_default: "要執行這項處理嗎?",
146
+ ui_action_confirm_ok: "是,執行",
147
+ ui_action_confirm_cancel: "取消",
148
+ ui_action_ack: "我已確認此操作無法復原",
149
+ ui_action_running: "處理中…",
150
+ ui_action_succeeded: "處理完成",
151
+ ui_action_failed: "未套用",
152
+ ui_action_unknown: "無法確認是否已執行 — 請先確認再重試。",
153
+ ui_action_reconcile: "再次確認",
154
+ ui_action_disabled: "此帳號未啟用處理功能。",
155
+ ui_action_risk_low: "風險低",
156
+ ui_action_risk_medium: "風險中",
157
+ ui_action_risk_high: "風險高",
102
158
  ui_overwrite_confirm: "要清除目前草稿並以 AI 草稿取代嗎?",
103
159
  not_configured: "AI 回覆建議尚未連接。",
104
160
  unsupported_api: "AI 回覆建議尚未連接。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcg-labs/cx-agent-hook",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/session.js CHANGED
@@ -20,6 +20,48 @@
20
20
  * 남기지 않는다 — 프라이버시 경계).
21
21
  */
22
22
 
23
+ /** 처리 액션 pending 저장소 — 세션 영속(_persist·TTL 30분·consume 삭제)과
24
+ * **수명이 다르다**: 요청은 전송 **전에** 남고, 종단 뒤 24h 까지 살아 새로고침·
25
+ * 재진입 시 "이미 눌렀던 것" 을 안다. 정본은 허브 원장이다 — 브라우저 소실은
26
+ * 기록 손실이 아니라 표시 손실일 뿐. localStorage 부재면 메모리로만 산다. */
27
+ export class ActionLedger {
28
+ constructor({ impl, prefix = "cx-agent-actions", terminalTtlMs = 24 * 60 * 60 * 1000 } = {}) {
29
+ this._impl = impl || (typeof localStorage !== "undefined" ? localStorage : null);
30
+ this._key = prefix;
31
+ this._ttl = terminalTtlMs;
32
+ this._mem = new Map();
33
+ }
34
+ _all() {
35
+ if (!this._impl) return this._mem;
36
+ try {
37
+ const raw = JSON.parse(this._impl.getItem(this._key) || "{}");
38
+ return new Map(Object.entries(raw));
39
+ } catch { return new Map(); }
40
+ }
41
+ _save(map) {
42
+ if (!this._impl) { this._mem = map; return; }
43
+ try { this._impl.setItem(this._key, JSON.stringify(Object.fromEntries(map))); } catch { /* quota */ }
44
+ }
45
+ put(requestId, rec) {
46
+ const m = this._all();
47
+ m.set(requestId, { ...rec, at: Date.now() });
48
+ this._save(this._prune(m));
49
+ }
50
+ get(requestId) { return this._all().get(requestId) || null; }
51
+ forInquiry(externalId) {
52
+ const out = [];
53
+ for (const [rid, r] of this._all()) if (r.externalId === externalId) out.push({ requestId: rid, ...r });
54
+ return out;
55
+ }
56
+ _prune(m) {
57
+ const now = Date.now();
58
+ for (const [rid, r] of m) {
59
+ if ((r.state === "succeeded" || r.state === "failed") && now - (r.at || 0) > this._ttl) m.delete(rid);
60
+ }
61
+ return m;
62
+ }
63
+ }
64
+
23
65
  const NOT_CONFIGURED = () => ({
24
66
  ok: false, answered: false, answer: "", answerId: null,
25
67
  evidence: [], declinedReason: "not_configured", raw: {},
@@ -109,6 +151,8 @@ export class InquirySession {
109
151
  this._statusText = "";
110
152
  this._answerId = null;
111
153
  this._composeSeq = 0;
154
+ this._offers = []; // 마지막 답변의 처리 선택지 (actions/1 §4)
155
+ this._actionUi = null;
112
156
 
113
157
  // 영속 복원 — TTL 만료분은 store 가 이미 걸렀다
114
158
  if (this._id && deps.store) {
@@ -123,6 +167,64 @@ export class InquirySession {
123
167
  }
124
168
 
125
169
  get externalId() { return this._id; }
170
+ /** 처리 선택지 (만료 제외). 실행 여부는 pendingActions() 로. */
171
+ get offers() {
172
+ const now = Date.now();
173
+ return this._offers.filter((o) => !o.expires_at || Date.parse(o.expires_at) > now);
174
+ }
175
+ attachActionUi(ui) { this._actionUi = ui; this._notifyActions(); }
176
+ detachActionUi() { this._actionUi = null; }
177
+ _notifyActions() {
178
+ if (this._actionUi && typeof this._actionUi.setOffers === "function") {
179
+ try { this._actionUi.setOffers(this.offers, this.pendingActions()); } catch { /* UI 오류가 세션을 죽이지 않는다 */ }
180
+ }
181
+ }
182
+ /** 이 문의에서 눌렀던 처리들 (pending·unknown·종단 24h 내) — 정본은 허브 원장 */
183
+ pendingActions() {
184
+ const d = this._d;
185
+ return d.actionLedger ? d.actionLedger.forInquiry(this._id) : [];
186
+ }
187
+ /**
188
+ * 처리 실행 — 사람 확인은 호출자(UI)가 끝낸 뒤 부른다. request_id 를 발급해
189
+ * **전송 전에** 영속하고, 재시도 0. 네트워크 오류면 상태 조회 1회 → 404(미도달)
190
+ * 일 때만 같은 request_id 로 1회 재전송 (실행된 적 없음이 확정이라 안전).
191
+ */
192
+ async executeAction(offer, { actorClaimed } = {}) {
193
+ const d = this._d;
194
+ if (!d.client || !offer || !offer.offer_id) return { ok: false, state: "", error: "not_configured" };
195
+ if (typeof d.actorAssertion !== "function") return { ok: false, state: "", error: "actor_assertion_missing" };
196
+ let assertion = "";
197
+ try { assertion = String(await d.actorAssertion() || ""); } catch { assertion = ""; }
198
+ if (!assertion) return { ok: false, state: "", error: "actor_assertion_missing" };
199
+ const requestId = d.newRequestId();
200
+ const rec = { externalId: this._id, offerId: offer.offer_id, actionKey: offer.action_key,
201
+ label: offer.label || "", state: "pending" };
202
+ if (d.actionLedger) d.actionLedger.put(requestId, rec);
203
+ const args = { requestId, offer, actorAssertion: assertion, actorClaimed, inquiryRef: this._id };
204
+ let r = await d.client.requestAction(args);
205
+ if (!r.ok && r.error === "network_error") {
206
+ const st = await d.client.actionStatus(requestId);
207
+ if (st.ok && st.state === "none") r = await d.client.requestAction(args); // 미도달 확정 — 1회 재전송
208
+ else if (st.ok) r = { ...r, ok: true, state: st.state, reasonCode: st.reasonCode, message: st.message, error: null };
209
+ else r = { ...r, state: "unknown", error: "network_error" };
210
+ }
211
+ if (d.actionLedger) d.actionLedger.put(requestId, { ...rec, state: r.state || (r.ok ? "unknown" : "failed"),
212
+ reasonCode: r.reasonCode || "", message: r.message || "" });
213
+ this._notifyActions();
214
+ return { requestId, ...r };
215
+ }
216
+ /** 재조정 — lookup 만. 재실행 버튼은 존재하지 않는다. */
217
+ async reconcileAction(requestId) {
218
+ const d = this._d;
219
+ if (!d.client || !requestId) return { ok: false, state: "", error: "not_configured" };
220
+ const r = await d.client.actionReconcile(requestId);
221
+ if (d.actionLedger && r.ok) {
222
+ const prev = d.actionLedger.get(requestId) || { externalId: this._id };
223
+ d.actionLedger.put(requestId, { ...prev, state: r.state, reasonCode: r.reasonCode || "", message: r.message || "" });
224
+ }
225
+ this._notifyActions();
226
+ return r;
227
+ }
126
228
  get state() { return this._state; }
127
229
  get draft() { return this._draft; }
128
230
  get adoptedAnswerId() { return this._answerId; }
@@ -263,6 +365,10 @@ export class InquirySession {
263
365
  // 않는다. 0.2.3: 이전 문의 초안이 새 에디터에 주입되던 오염 경로 차단.
264
366
  return result;
265
367
  }
368
+ // 처리 선택지 — 답변·거절 무관하게 payload 에 실린다 (만료는 expires_at)
369
+ this._offers = Array.isArray(result.raw && result.raw.suggested_actions)
370
+ ? result.raw.suggested_actions.filter((o) => o && o.offer_id) : [];
371
+ this._notifyActions();
266
372
  if (result.answered) {
267
373
  this._draft = decorate(result.answer); // 정본 확정 (strip+데코레이터)
268
374
  this._setUiDraft(this._draft);
package/view.d.ts CHANGED
@@ -42,3 +42,22 @@ export interface AiSuggestNode {
42
42
  export declare function aiSuggestTree(
43
43
  input: Parameters<typeof aiSuggestView>[0],
44
44
  ): AiSuggestNode;
45
+
46
+ // ── 처리 액션 (actions/1) 뷰 ──
47
+ export declare const ACTION_CLS: Record<
48
+ "root" | "item" | "label" | "params" | "risk" | "button" | "confirm" | "confirmText" | "ack" | "status" | "reconcile",
49
+ string
50
+ >;
51
+ export interface ActionOfferItemView {
52
+ offerId: string; label: string; params: string; riskLabel: string; risk: string;
53
+ canExecute: boolean; buttonLabel: string; confirming: boolean; confirmText: string;
54
+ needsAck: boolean; ackLabel: string; okLabel: string; cancelLabel: string;
55
+ state: string; statusText: string; terminal: boolean;
56
+ canReconcile: boolean; reconcileLabel: string; requestId: string;
57
+ }
58
+ export declare function actionOffersView(input: {
59
+ offers?: unknown[]; pending?: unknown[]; enabled?: boolean; confirming?: string | null;
60
+ messages?: Record<string, string>;
61
+ }): { visible: boolean; items: ActionOfferItemView[]; disabledHint: string };
62
+ /** action: "confirm" | "execute" | "cancel" | "reconcile" | "ack" — 텍스트 노드만 */
63
+ export declare function actionOffersTree(input: Parameters<typeof actionOffersView>[0]): AiSuggestNode | null;
package/view.js CHANGED
@@ -103,3 +103,100 @@ export function aiSuggestTree(input) {
103
103
  }
104
104
  return { tag: "div", cls: CLS.root, children };
105
105
  }
106
+
107
+
108
+ // ── 처리 액션 (actions/1) — 선택지·확인·3상태의 표시 판단 ─────────────────
109
+ //
110
+ // 실행 시맨틱은 여기 없다. offers 는 답변 payload 의 suggested_actions 그대로,
111
+ // pending 은 세션 ActionLedger 그대로 — 이 함수는 "무엇을 어떤 상태로 보일지"
112
+ // 만 결정한다. 텍스트 노드만 (innerHTML 금지 — 고객사 기입 문구가 실린다).
113
+
114
+ export const ACTION_CLS = {
115
+ root: "fcx-act",
116
+ item: "fcx-act-item",
117
+ label: "fcx-act-label",
118
+ params: "fcx-act-params",
119
+ risk: "fcx-act-risk",
120
+ button: "fcx-act-button",
121
+ confirm: "fcx-act-confirm",
122
+ confirmText: "fcx-act-confirm-text",
123
+ ack: "fcx-act-ack",
124
+ status: "fcx-act-status",
125
+ reconcile: "fcx-act-reconcile",
126
+ };
127
+
128
+ const RISK_KEY = { low: "ui_action_risk_low", medium: "ui_action_risk_medium", high: "ui_action_risk_high" };
129
+
130
+ /**
131
+ * @param {Array} offers session.offers
132
+ * @param {Array} pending session.pendingActions() [{requestId, offerId, state, reasonCode, message, label}]
133
+ * @param {boolean} enabled agent.actionsEnabled
134
+ * @param {string|null} confirming 확인 패널이 열린 offer_id
135
+ * @param {Record<string,string>} messages
136
+ */
137
+ export function actionOffersView({ offers = [], pending = [], enabled = true, confirming = null, messages = {} }) {
138
+ const byOffer = new Map();
139
+ for (const p of pending) byOffer.set(p.offerId, p);
140
+ const items = offers.map((o) => {
141
+ const p = byOffer.get(o.offer_id) || null;
142
+ const state = p ? p.state : "";
143
+ const terminal = state === "succeeded" || state === "failed";
144
+ let statusText = "";
145
+ if (state === "pending") statusText = messages.ui_action_running;
146
+ else if (state === "succeeded") statusText = messages.ui_action_succeeded;
147
+ else if (state === "failed") statusText = `${messages.ui_action_failed}${p && p.message ? " — " + p.message : ""}`;
148
+ else if (state === "unknown") statusText = messages.ui_action_unknown;
149
+ return {
150
+ offerId: o.offer_id, label: o.label || o.action_key, params: o.params_display || "",
151
+ riskLabel: messages[RISK_KEY[o.risk_level] || RISK_KEY.low] || "",
152
+ risk: o.risk_level || "low",
153
+ // 실행 버튼: 표면 열림 ∧ 미실행(또는 실패-재시도 가능) — unknown 은 재실행 없음
154
+ canExecute: enabled && (!p || (state === "failed")),
155
+ buttonLabel: messages.ui_action_execute,
156
+ confirming: confirming === o.offer_id,
157
+ confirmText: o.confirm || messages.ui_action_confirm_default,
158
+ needsAck: o.risk_level === "high",
159
+ ackLabel: messages.ui_action_ack,
160
+ okLabel: messages.ui_action_confirm_ok,
161
+ cancelLabel: messages.ui_action_confirm_cancel,
162
+ state, statusText, terminal,
163
+ // unknown 만 "다시 확인" (reconcile=lookup) — 재실행 버튼은 없다
164
+ canReconcile: state === "unknown",
165
+ reconcileLabel: messages.ui_action_reconcile,
166
+ requestId: p ? p.requestId : "",
167
+ };
168
+ });
169
+ return { visible: items.length > 0, items, disabledHint: enabled ? "" : messages.ui_action_disabled };
170
+ }
171
+
172
+ /** 프레임워크 중립 트리 — action: "confirm"(패널 열기) / "execute" / "cancel" / "reconcile" / "ack" */
173
+ export function actionOffersTree(input) {
174
+ const v = actionOffersView(input);
175
+ if (!v.visible) return null;
176
+ const items = v.items.map((it) => {
177
+ const children = [
178
+ { tag: "div", cls: ACTION_CLS.label, text: it.label },
179
+ ...(it.params ? [{ tag: "div", cls: ACTION_CLS.params, text: it.params }] : []),
180
+ { tag: "span", cls: ACTION_CLS.risk, text: it.riskLabel, risk: it.risk },
181
+ ];
182
+ if (it.confirming) {
183
+ children.push({
184
+ tag: "div", cls: ACTION_CLS.confirm, children: [
185
+ { tag: "div", cls: ACTION_CLS.confirmText, text: it.confirmText },
186
+ ...(it.needsAck ? [{ tag: "label", cls: ACTION_CLS.ack, text: it.ackLabel, action: "ack", offerId: it.offerId }] : []),
187
+ { tag: "button", cls: ACTION_CLS.button, type: "button", text: it.okLabel, action: "execute", offerId: it.offerId },
188
+ { tag: "button", cls: ACTION_CLS.button, type: "button", text: it.cancelLabel, action: "cancel", offerId: it.offerId },
189
+ ],
190
+ });
191
+ } else if (it.canExecute) {
192
+ children.push({ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.buttonLabel, action: "confirm", offerId: it.offerId });
193
+ }
194
+ if (it.statusText) children.push({ tag: "div", cls: ACTION_CLS.status, text: it.statusText, state: it.state });
195
+ if (it.canReconcile) {
196
+ children.push({ tag: "button", cls: ACTION_CLS.reconcile, type: "button", text: it.reconcileLabel, action: "reconcile", requestId: it.requestId });
197
+ }
198
+ return { tag: "div", cls: ACTION_CLS.item, children };
199
+ });
200
+ if (v.disabledHint) items.push({ tag: "div", cls: ACTION_CLS.status, text: v.disabledHint });
201
+ return { tag: "div", cls: ACTION_CLS.root, children: items };
202
+ }