@fcg-labs/cx-agent-hook 0.4.0 → 0.5.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/view.js CHANGED
@@ -105,11 +105,17 @@ export function aiSuggestTree(input) {
105
105
  }
106
106
 
107
107
 
108
- // ── 처리 액션 (actions/1) — 선택지·확인·3상태의 표시 판단 ─────────────────
108
+ // ── 처리 액션 (actions/1 프로파일 B) — 선택지·확인·상태·잠금의 표시 판단 ─────
109
109
  //
110
- // 실행 시맨틱은 여기 없다. offers 는 답변 payload suggested_actions 그대로,
111
- // pending 은 세션 ActionLedger 그대로 — 이 함수는 "무엇을 어떤 상태로 보일지"
112
- // 만 결정한다. 텍스트 노드만 (innerHTML 금지 — 고객사 기입 문구가 실린다).
110
+ // 실행 시맨틱은 여기 없다. offers 는 session.offers(available·locked 병합), pending
111
+ // 세션 ActionLedger 그대로 — 이 함수는 "무엇을 어떤 상태로 보일지"만 결정한다.
112
+ // 텍스트 노드만 (innerHTML 금지 — 고객사 기입 문구가 실린다).
113
+ //
114
+ // 카드 상태 (하나만):
115
+ // locked 같은 대상·같은 입력의 최근 성공 기록 — "이미 처리됨 · 시각 · 행위자", 버튼 없음
116
+ // unavailable 이 CMS 가 그 API 를 모름(endpoint_key 부재) — 회색·사유, 버튼 없음
117
+ // pending/received 실행 중 · succeeded · failed(재시도 가능) · unknown(확인 필요, 재실행 없음)
118
+ // unreported 실행은 끝났으나 허브 보고가 안 됨 — "결과 다시 보고" (재실행 아님)
113
119
 
114
120
  export const ACTION_CLS = {
115
121
  root: "fcx-act",
@@ -122,14 +128,23 @@ export const ACTION_CLS = {
122
128
  confirmText: "fcx-act-confirm-text",
123
129
  ack: "fcx-act-ack",
124
130
  status: "fcx-act-status",
125
- reconcile: "fcx-act-reconcile",
131
+ locked: "fcx-act-locked",
132
+ resend: "fcx-act-resend",
126
133
  };
127
134
 
128
135
  const RISK_KEY = { low: "ui_action_risk_low", medium: "ui_action_risk_medium", high: "ui_action_risk_high" };
129
136
 
137
+ function fmtWhen(iso) {
138
+ if (!iso) return "";
139
+ const d = new Date(iso);
140
+ if (Number.isNaN(d.getTime())) return String(iso);
141
+ const p = (n) => String(n).padStart(2, "0");
142
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
143
+ }
144
+
130
145
  /**
131
- * @param {Array} offers session.offers
132
- * @param {Array} pending session.pendingActions() [{requestId, offerId, state, reasonCode, message, label}]
146
+ * @param {Array} offers session.offers (available·locked·lock 병합됨)
147
+ * @param {Array} pending session.pendingActions() [{requestId, offerId, state, reasonCode, message, label, unreported}]
133
148
  * @param {boolean} enabled agent.actionsEnabled
134
149
  * @param {string|null} confirming 확인 패널이 열린 offer_id
135
150
  * @param {Record<string,string>} messages
@@ -141,17 +156,27 @@ export function actionOffersView({ offers = [], pending = [], enabled = true, co
141
156
  const p = byOffer.get(o.offer_id) || null;
142
157
  const state = p ? p.state : "";
143
158
  const terminal = state === "succeeded" || state === "failed";
159
+ const available = o.available !== false;
160
+ const locked = Boolean(o.locked) && !p;
144
161
  let statusText = "";
145
- if (state === "pending") statusText = messages.ui_action_running;
162
+ if (state === "pending" || state === "received") statusText = messages.ui_action_running;
146
163
  else if (state === "succeeded") statusText = messages.ui_action_succeeded;
147
164
  else if (state === "failed") statusText = `${messages.ui_action_failed}${p && p.message ? " — " + p.message : ""}`;
148
165
  else if (state === "unknown") statusText = messages.ui_action_unknown;
166
+ if (p && p.unreported) statusText = `${statusText} · ${messages.ui_action_unreported}`;
167
+ let lockedText = "";
168
+ if (locked) {
169
+ const who = o.lock && o.lock.actorClaimed ? ` · ${o.lock.actorClaimed}` : "";
170
+ lockedText = `${messages.ui_action_locked} · ${fmtWhen(o.lock && o.lock.at)}${who}`;
171
+ }
149
172
  return {
150
173
  offerId: o.offer_id, label: o.label || o.action_key, params: o.params_display || "",
151
174
  riskLabel: messages[RISK_KEY[o.risk_level] || RISK_KEY.low] || "",
152
175
  risk: o.risk_level || "low",
153
- // 실행 버튼: 표면 열림 ∧ 미실행(또는 실패-재시도 가능) — unknown 은 재실행 없음
154
- canExecute: enabled && (!p || (state === "failed")),
176
+ available, locked, lockedText,
177
+ unavailableText: available ? "" : messages.ui_action_unavailable,
178
+ // 실행 버튼: 표면 열림 ∧ 이 CMS 가 API 를 앎 ∧ 잠기지 않음 ∧ 미실행(또는 실패-재시도 가능)
179
+ canExecute: enabled && available && !locked && (!p || state === "failed"),
155
180
  buttonLabel: messages.ui_action_execute,
156
181
  confirming: confirming === o.offer_id,
157
182
  confirmText: o.confirm || messages.ui_action_confirm_default,
@@ -160,16 +185,18 @@ export function actionOffersView({ offers = [], pending = [], enabled = true, co
160
185
  okLabel: messages.ui_action_confirm_ok,
161
186
  cancelLabel: messages.ui_action_confirm_cancel,
162
187
  state, statusText, terminal,
163
- // unknown "다시 확인" (reconcile=lookup) — 재실행 버튼은 없다
164
- canReconcile: state === "unknown",
165
- reconcileLabel: messages.ui_action_reconcile,
188
+ // 미보고만 "결과 다시 보고" — 재실행 버튼은 없다 (unknown 은 확인 필요로 남는다)
189
+ canResend: Boolean(p && p.unreported),
190
+ resendLabel: messages.ui_action_resend,
166
191
  requestId: p ? p.requestId : "",
167
192
  };
168
193
  });
169
194
  return { visible: items.length > 0, items, disabledHint: enabled ? "" : messages.ui_action_disabled };
170
195
  }
171
196
 
172
- /** 프레임워크 중립 트리 — action: "confirm"(패널 열기) / "execute" / "cancel" / "reconcile" / "ack" */
197
+ /** 프레임워크 중립 트리 — action: "confirm"(패널 열기) / "execute" / "cancel" / "resend" / "ack".
198
+ * 노드 필드: tag·cls·text·children·type·action·offerId·requestId·risk·state·live(상태 = role=status)·
199
+ * pressed(ack 토글 — 어댑터가 aria-pressed 를 offer 단위 ack 로 채운다). */
173
200
  export function actionOffersTree(input) {
174
201
  const v = actionOffersView(input);
175
202
  if (!v.visible) return null;
@@ -179,11 +206,16 @@ export function actionOffersTree(input) {
179
206
  ...(it.params ? [{ tag: "div", cls: ACTION_CLS.params, text: it.params }] : []),
180
207
  { tag: "span", cls: ACTION_CLS.risk, text: it.riskLabel, risk: it.risk },
181
208
  ];
182
- if (it.confirming) {
209
+ if (it.locked) {
210
+ children.push({ tag: "div", cls: ACTION_CLS.locked, text: it.lockedText });
211
+ } else if (!it.available) {
212
+ children.push({ tag: "div", cls: ACTION_CLS.status, text: it.unavailableText, state: "unavailable" });
213
+ } else if (it.confirming) {
183
214
  children.push({
184
215
  tag: "div", cls: ACTION_CLS.confirm, children: [
185
216
  { 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 }] : []),
217
+ // 고위험 2단 확인 토글 **버튼**(aria-pressed): 포커스·Space/Enter 되어야 키보드 사용자도 실행할 있다
218
+ ...(it.needsAck ? [{ tag: "button", type: "button", cls: ACTION_CLS.ack, text: it.ackLabel, action: "ack", offerId: it.offerId, pressed: true }] : []),
187
219
  { tag: "button", cls: ACTION_CLS.button, type: "button", text: it.okLabel, action: "execute", offerId: it.offerId },
188
220
  { tag: "button", cls: ACTION_CLS.button, type: "button", text: it.cancelLabel, action: "cancel", offerId: it.offerId },
189
221
  ],
@@ -191,9 +223,9 @@ export function actionOffersTree(input) {
191
223
  } else if (it.canExecute) {
192
224
  children.push({ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.buttonLabel, action: "confirm", offerId: it.offerId });
193
225
  }
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 });
226
+ if (it.statusText) children.push({ tag: "div", cls: ACTION_CLS.status, text: it.statusText, state: it.state, live: true });
227
+ if (it.canResend) {
228
+ children.push({ tag: "button", cls: ACTION_CLS.resend, type: "button", text: it.resendLabel, action: "resend", requestId: it.requestId });
197
229
  }
198
230
  return { tag: "div", cls: ACTION_CLS.item, children };
199
231
  });
package/vue.d.ts CHANGED
@@ -15,4 +15,14 @@ export interface AiSuggestPanelProps {
15
15
 
16
16
  /** `@adopt` 로 채택 문구를 emit 한다. hook 이 미설정이면 아무것도 그리지 않는다. */
17
17
  export declare const AiSuggestPanel: DefineComponent<AiSuggestPanelProps>;
18
+
19
+ export interface ActionOffersPanelProps {
20
+ session: import("./agent.js").InquirySession;
21
+ agent: import("./agent.js").CxAgent;
22
+ actorClaimed?: string;
23
+ className?: string;
24
+ }
25
+ /** 처리 선택지 카드 (actions/1 프로파일 B) — emits "result" */
26
+ export declare const ActionOffersPanel: DefineComponent<ActionOffersPanelProps>;
27
+
18
28
  export default AiSuggestPanel;
package/vue.js CHANGED
@@ -19,9 +19,9 @@
19
19
  * JSX·SFC 를 쓰지 않는 이유는 react.js 와 같다: 이 패키지에는 빌드 단계가 없다.
20
20
  * Vue 는 optional peer 다 (>=3.0).
21
21
  */
22
- import { computed, defineComponent, h, ref, watch } from "vue";
22
+ import { computed, defineComponent, h, onBeforeUnmount, ref, watch } from "vue";
23
23
 
24
- import { aiSuggestTree, aiSuggestView, CLS } from "./view.js";
24
+ import { actionOffersTree, aiSuggestTree, aiSuggestView, CLS } from "./view.js";
25
25
 
26
26
  export { aiSuggestView };
27
27
 
@@ -107,4 +107,82 @@ export const AiSuggestPanel = defineComponent({
107
107
  },
108
108
  });
109
109
 
110
+ // ── 처리 선택지 카드 (actions/1 프로파일 B) — react.js ActionOffersPanel 과 같은 규약 ──
111
+ //
112
+ // ```vue
113
+ // <ActionOffersPanel :session="getCsSession(csId)" :agent="agent" :actor-claimed="answerWriter" @result="onResult" />
114
+ // ```
115
+ export const ActionOffersPanel = defineComponent({
116
+ name: "ActionOffersPanel",
117
+ props: {
118
+ session: { type: Object, required: true },
119
+ agent: { type: Object, required: true },
120
+ actorClaimed: { type: String, default: "" },
121
+ className: { type: String, default: "" },
122
+ },
123
+ emits: ["result"],
124
+ setup(props, { emit }) {
125
+ const offers = ref(props.session ? props.session.offers : []);
126
+ const pending = ref(props.session ? props.session.pendingActions() : []);
127
+ const confirming = ref(null);
128
+ const ackedOffer = ref(null); // 고위험 확인은 offer 단위
129
+ let attached = null;
130
+ const attach = (s) => {
131
+ if (attached && typeof attached.detachActionUi === "function") attached.detachActionUi();
132
+ attached = s || null;
133
+ confirming.value = null; ackedOffer.value = null;
134
+ if (s && typeof s.attachActionUi === "function") {
135
+ s.attachActionUi({ setOffers: (o, p) => { offers.value = o; pending.value = p; } });
136
+ }
137
+ };
138
+ watch(() => props.session, attach, { immediate: true });
139
+ onBeforeUnmount(() => attach(null));
140
+
141
+ const execute = async (offerId) => {
142
+ const offer = offers.value.find((o) => o.offer_id === offerId);
143
+ if (!offer) return;
144
+ if (offer.risk_level === "high" && ackedOffer.value !== offerId) return;
145
+ confirming.value = null; ackedOffer.value = null;
146
+ const r = await props.session.executeAction(offer, { actorClaimed: props.actorClaimed });
147
+ emit("result", r);
148
+ };
149
+ const actions = {
150
+ confirm: (e) => { confirming.value = e.currentTarget.dataset.offer || null; ackedOffer.value = null; },
151
+ cancel: () => { confirming.value = null; ackedOffer.value = null; },
152
+ ack: (e) => { const id = e.currentTarget.dataset.offer; ackedOffer.value = ackedOffer.value === id ? null : id; },
153
+ execute: (e) => execute(e.currentTarget.dataset.offer),
154
+ resend: (e) => props.session.resendResult(e.currentTarget.dataset.request),
155
+ };
156
+ const tree = computed(() => actionOffersTree({
157
+ offers: offers.value, pending: pending.value, enabled: Boolean(props.agent && props.agent.actionsEnabled),
158
+ confirming: confirming.value, messages: props.agent ? props.agent.messages : {},
159
+ }));
160
+ const render = (node) => {
161
+ const p = { class: node.cls };
162
+ if (node.tag === "button") p.type = node.type || "button";
163
+ if (node.action && actions[node.action]) {
164
+ p.onClick = actions[node.action];
165
+ if (node.offerId) p["data-offer"] = node.offerId;
166
+ if (node.requestId) p["data-request"] = node.requestId;
167
+ if (node.action === "execute" && confirming.value) {
168
+ const o = offers.value.find((x) => x.offer_id === node.offerId);
169
+ if (o && o.risk_level === "high" && ackedOffer.value !== node.offerId) p.disabled = true;
170
+ }
171
+ if (node.pressed) p["aria-pressed"] = ackedOffer.value === node.offerId;
172
+ }
173
+ if (node.live) p.role = "status";
174
+ if (node.state) p["data-state"] = node.state;
175
+ if (node.risk) p["data-risk"] = node.risk;
176
+ return h(node.tag, p, node.children ? node.children.map(render) : node.text);
177
+ };
178
+ return () => {
179
+ if (!props.session || !props.agent) return null;
180
+ const t = tree.value;
181
+ if (!t) return null;
182
+ return h("div", { class: props.className ? `${t.cls} ${props.className}` : t.cls,
183
+ onClick: (event) => event.stopPropagation() }, t.children.map(render));
184
+ };
185
+ },
186
+ });
187
+
110
188
  export default AiSuggestPanel;