@fcg-labs/cx-agent-hook 0.4.0 → 0.5.1

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,24 @@ 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",
133
+ successCopy: "fcx-act-success-copy",
126
134
  };
127
135
 
128
136
  const RISK_KEY = { low: "ui_action_risk_low", medium: "ui_action_risk_medium", high: "ui_action_risk_high" };
129
137
 
138
+ function fmtWhen(iso) {
139
+ if (!iso) return "";
140
+ const d = new Date(iso);
141
+ if (Number.isNaN(d.getTime())) return String(iso);
142
+ const p = (n) => String(n).padStart(2, "0");
143
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
144
+ }
145
+
130
146
  /**
131
- * @param {Array} offers session.offers
132
- * @param {Array} pending session.pendingActions() [{requestId, offerId, state, reasonCode, message, label}]
147
+ * @param {Array} offers session.offers (available·locked·lock 병합됨)
148
+ * @param {Array} pending session.pendingActions() [{requestId, offerId, state, reasonCode, message, label, unreported}]
133
149
  * @param {boolean} enabled agent.actionsEnabled
134
150
  * @param {string|null} confirming 확인 패널이 열린 offer_id
135
151
  * @param {Record<string,string>} messages
@@ -141,17 +157,30 @@ export function actionOffersView({ offers = [], pending = [], enabled = true, co
141
157
  const p = byOffer.get(o.offer_id) || null;
142
158
  const state = p ? p.state : "";
143
159
  const terminal = state === "succeeded" || state === "failed";
160
+ const available = o.available !== false;
161
+ const locked = Boolean(o.locked) && !p;
144
162
  let statusText = "";
145
- if (state === "pending") statusText = messages.ui_action_running;
163
+ if (state === "pending" || state === "received") statusText = messages.ui_action_running;
146
164
  else if (state === "succeeded") statusText = messages.ui_action_succeeded;
147
165
  else if (state === "failed") statusText = `${messages.ui_action_failed}${p && p.message ? " — " + p.message : ""}`;
148
166
  else if (state === "unknown") statusText = messages.ui_action_unknown;
167
+ if (p && p.unreported) statusText = `${statusText} · ${messages.ui_action_unreported}`;
168
+ // success_copy 는 실행이 실제로 성공한 뒤에만 — 절차 자산의 "실행 성공 후 문구"
169
+ // (실행 전 카드·답변에 실으면 완료 사칭이 된다. 서버도 같은 이유로 승인 게이트를 둔다.)
170
+ const successCopy = state === "succeeded" ? String(o.success_copy || "") : "";
171
+ let lockedText = "";
172
+ if (locked) {
173
+ const who = o.lock && o.lock.actorClaimed ? ` · ${o.lock.actorClaimed}` : "";
174
+ lockedText = `${messages.ui_action_locked} · ${fmtWhen(o.lock && o.lock.at)}${who}`;
175
+ }
149
176
  return {
150
177
  offerId: o.offer_id, label: o.label || o.action_key, params: o.params_display || "",
151
178
  riskLabel: messages[RISK_KEY[o.risk_level] || RISK_KEY.low] || "",
152
179
  risk: o.risk_level || "low",
153
- // 실행 버튼: 표면 열림 ∧ 미실행(또는 실패-재시도 가능) — unknown 은 재실행 없음
154
- canExecute: enabled && (!p || (state === "failed")),
180
+ available, locked, lockedText,
181
+ unavailableText: available ? "" : messages.ui_action_unavailable,
182
+ // 실행 버튼: 표면 열림 ∧ 이 CMS 가 API 를 앎 ∧ 잠기지 않음 ∧ 미실행(또는 실패-재시도 가능)
183
+ canExecute: enabled && available && !locked && (!p || state === "failed"),
155
184
  buttonLabel: messages.ui_action_execute,
156
185
  confirming: confirming === o.offer_id,
157
186
  confirmText: o.confirm || messages.ui_action_confirm_default,
@@ -159,17 +188,19 @@ export function actionOffersView({ offers = [], pending = [], enabled = true, co
159
188
  ackLabel: messages.ui_action_ack,
160
189
  okLabel: messages.ui_action_confirm_ok,
161
190
  cancelLabel: messages.ui_action_confirm_cancel,
162
- state, statusText, terminal,
163
- // unknown "다시 확인" (reconcile=lookup) — 재실행 버튼은 없다
164
- canReconcile: state === "unknown",
165
- reconcileLabel: messages.ui_action_reconcile,
191
+ state, statusText, terminal, successCopy,
192
+ // 미보고만 "결과 다시 보고" — 재실행 버튼은 없다 (unknown 은 확인 필요로 남는다)
193
+ canResend: Boolean(p && p.unreported),
194
+ resendLabel: messages.ui_action_resend,
166
195
  requestId: p ? p.requestId : "",
167
196
  };
168
197
  });
169
198
  return { visible: items.length > 0, items, disabledHint: enabled ? "" : messages.ui_action_disabled };
170
199
  }
171
200
 
172
- /** 프레임워크 중립 트리 — action: "confirm"(패널 열기) / "execute" / "cancel" / "reconcile" / "ack" */
201
+ /** 프레임워크 중립 트리 — action: "confirm"(패널 열기) / "execute" / "cancel" / "resend" / "ack".
202
+ * 노드 필드: tag·cls·text·children·type·action·offerId·requestId·risk·state·live(상태 = role=status)·
203
+ * pressed(ack 토글 — 어댑터가 aria-pressed 를 offer 단위 ack 로 채운다). */
173
204
  export function actionOffersTree(input) {
174
205
  const v = actionOffersView(input);
175
206
  if (!v.visible) return null;
@@ -179,11 +210,16 @@ export function actionOffersTree(input) {
179
210
  ...(it.params ? [{ tag: "div", cls: ACTION_CLS.params, text: it.params }] : []),
180
211
  { tag: "span", cls: ACTION_CLS.risk, text: it.riskLabel, risk: it.risk },
181
212
  ];
182
- if (it.confirming) {
213
+ if (it.locked) {
214
+ children.push({ tag: "div", cls: ACTION_CLS.locked, text: it.lockedText });
215
+ } else if (!it.available) {
216
+ children.push({ tag: "div", cls: ACTION_CLS.status, text: it.unavailableText, state: "unavailable" });
217
+ } else if (it.confirming) {
183
218
  children.push({
184
219
  tag: "div", cls: ACTION_CLS.confirm, children: [
185
220
  { 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 }] : []),
221
+ // 고위험 2단 확인 토글 **버튼**(aria-pressed): 포커스·Space/Enter 되어야 키보드 사용자도 실행할 있다
222
+ ...(it.needsAck ? [{ tag: "button", type: "button", cls: ACTION_CLS.ack, text: it.ackLabel, action: "ack", offerId: it.offerId, pressed: true }] : []),
187
223
  { tag: "button", cls: ACTION_CLS.button, type: "button", text: it.okLabel, action: "execute", offerId: it.offerId },
188
224
  { tag: "button", cls: ACTION_CLS.button, type: "button", text: it.cancelLabel, action: "cancel", offerId: it.offerId },
189
225
  ],
@@ -191,9 +227,10 @@ export function actionOffersTree(input) {
191
227
  } else if (it.canExecute) {
192
228
  children.push({ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.buttonLabel, action: "confirm", offerId: it.offerId });
193
229
  }
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 });
230
+ if (it.statusText) children.push({ tag: "div", cls: ACTION_CLS.status, text: it.statusText, state: it.state, live: true });
231
+ if (it.successCopy) children.push({ tag: "div", cls: ACTION_CLS.successCopy, text: it.successCopy });
232
+ if (it.canResend) {
233
+ children.push({ tag: "button", cls: ACTION_CLS.resend, type: "button", text: it.resendLabel, action: "resend", requestId: it.requestId });
197
234
  }
198
235
  return { tag: "div", cls: ACTION_CLS.item, children };
199
236
  });
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;