@fcg-labs/cx-agent-hook 0.5.0 → 0.6.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 +25 -0
- package/actions.js +76 -0
- package/element.js +11 -2
- package/locales.js +16 -0
- package/package.json +1 -1
- package/react.js +9 -2
- package/session.js +42 -1
- package/styles.css +12 -0
- package/view.js +37 -3
- package/vue.js +9 -2
package/MIGRATION.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
# 0.5.x → 0.6.0 마이그레이션
|
|
2
|
+
|
|
3
|
+
## 요약 — 대상 확정(더블체크, 계약 §B.10)이 추가된다. **깨지는 것 없음** (additive)
|
|
4
|
+
|
|
5
|
+
문의 컨텍스트 값(예: subUserId)이 PK 라는 보장이 없다 — 오타깃 실행은 다른 유저
|
|
6
|
+
데이터 손상이다. 0.6.0 부터 절차 자산이 `verify`(대상 확정)를 들고 오면 SDK 가
|
|
7
|
+
쓰기 전에 **읽기 전용 API 로 대상을 확인**한다. `verify` 없는 offer 는 0.5.x 와
|
|
8
|
+
바이트 동일하게 동작한다. 고객사 코드 변경 0 — `adminApi` 그대로.
|
|
9
|
+
|
|
10
|
+
## 새로 생긴 것
|
|
11
|
+
|
|
12
|
+
- `offer.verify` — `{action_key(읽기), execution(GET 사양), display_fields[{path,label}],
|
|
13
|
+
preconditions[], resolution}`. 서버(asset v6)가 동봉한다.
|
|
14
|
+
- 실행 흐름: 선점 → **verify 읽기(GET 강제·재시도 0)** → precondition 결정적 평가 →
|
|
15
|
+
쓰기(읽기 직후 즉시) → 보고. 차단은 전부 `failed` 로 허브 원장에 남는다 —
|
|
16
|
+
reasonCode `precondition_failed`(deny_copy 가 메시지) · `verify_unavailable` ·
|
|
17
|
+
`verify_not_readonly` · `verify_manual`.
|
|
18
|
+
- 확인 패널: 열릴 때 같은 읽기로 **대상 확인 블록**(`fcx-act-verify*`)을 렌더하고,
|
|
19
|
+
확인이 ok 되기 전에는 실행 버튼이 나오지 않는다 (위험도 무관).
|
|
20
|
+
- `session.verifyAction(offer)`(패널 미리보기) · `session.actionVerifies()`,
|
|
21
|
+
순수 함수 `pluck`·`evaluatePreconditions`·`verifyViaAdminApi` (actions.js).
|
|
22
|
+
- 로케일 키 4종: `ui_action_verify_title/loading/blocked/failed` (ko·en·ja·zh-TW).
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
1
26
|
# 0.4.0 → 0.5.0 마이그레이션
|
|
2
27
|
|
|
3
28
|
## 요약 — 처리 액션 표면만 바뀐다 (0.3.0 표면은 그대로)
|
package/actions.js
CHANGED
|
@@ -133,6 +133,82 @@ function normalizeResponse(res) {
|
|
|
133
133
|
return { httpStatus: 200, body: res };
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** 응답 JSON 경로 추출 — `pluck(body, "data.gender")`. 없으면 undefined (결정적). */
|
|
137
|
+
export function pluck(obj, path) {
|
|
138
|
+
let cur = obj;
|
|
139
|
+
for (const part of String(path || "").split(".")) {
|
|
140
|
+
if (!part) continue;
|
|
141
|
+
if (cur === null || cur === undefined || typeof cur !== "object") return undefined;
|
|
142
|
+
cur = cur[part];
|
|
143
|
+
}
|
|
144
|
+
return cur;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 대상 확정 precondition 의 결정적 평가 — 계약 §B.10. LLM 판정 없음.
|
|
149
|
+
* not_equals_param: 응답값 === 바인딩값 이면 차단 (예: 현재 성별 === 요청 성별 → 변경 불요)
|
|
150
|
+
* equals_param: 응답값 !== 바인딩값 이면 차단 (대상 동일성 확인)
|
|
151
|
+
* equals_value: 응답값 !== 리터럴 이면 차단
|
|
152
|
+
* @returns {{blocked:boolean, path:string, denyCopy:string}}
|
|
153
|
+
*/
|
|
154
|
+
export function evaluatePreconditions(preconditions, body, paramsBound) {
|
|
155
|
+
const bound = paramsBound && typeof paramsBound === "object" ? paramsBound : {};
|
|
156
|
+
for (const p of Array.isArray(preconditions) ? preconditions : []) {
|
|
157
|
+
if (!p || typeof p !== "object" || !p.path) continue;
|
|
158
|
+
const val = pluck(body, p.path);
|
|
159
|
+
let blocked = false;
|
|
160
|
+
if (p.not_equals_param) blocked = String(val) === String(bound[p.not_equals_param]);
|
|
161
|
+
else if (p.equals_param) blocked = String(val) !== String(bound[p.equals_param]);
|
|
162
|
+
else if (p.equals_value !== undefined && p.equals_value !== "") blocked = String(val) !== String(p.equals_value);
|
|
163
|
+
if (blocked) return { blocked: true, path: String(p.path), denyCopy: String(p.deny_copy || "") };
|
|
164
|
+
}
|
|
165
|
+
return { blocked: false, path: "", denyCopy: "" };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 대상 확정(더블체크) 읽기 — 계약 §B.10 (T2.7). 쓰기 전에 offer.verify 의 **읽기 전용**
|
|
170
|
+
* API 로 대상을 조회해 ① 표시 항목(display_fields)을 뽑고 ② precondition 을 결정적으로
|
|
171
|
+
* 평가한다. 문의 컨텍스트 값이 PK 라는 보장이 없다 — 오타깃 실행은 타인 데이터 손상이라
|
|
172
|
+
* 읽기 실패·비 2xx·GET 아님은 전부 **실행 차단**(fail-closed)이다.
|
|
173
|
+
* @returns {Promise<{ok:boolean, skipped?:boolean, reasonCode?:string, message?:string,
|
|
174
|
+
* denyCopy?:string, display:Array<{label:string,value:string}>, httpStatus?:number}>}
|
|
175
|
+
*/
|
|
176
|
+
export async function verifyViaAdminApi(adminApi, offer) {
|
|
177
|
+
const v = offer && offer.verify;
|
|
178
|
+
if (!v || typeof v !== "object") return { ok: true, skipped: true, display: [] };
|
|
179
|
+
if (v.resolution === "manual") {
|
|
180
|
+
// 단건 조회로 대상 확정 불가 선언 — 자동 실행 경로가 아예 아니다 (서버 조립도 막지만 이중 방어)
|
|
181
|
+
return { ok: false, reasonCode: "verify_manual", display: [] };
|
|
182
|
+
}
|
|
183
|
+
const resolved = resolveExecution(v.execution, offer && offer.params_bound, adminApi && adminApi.endpoints);
|
|
184
|
+
if (!resolved.ok) return { ok: false, reasonCode: "verify_unavailable", message: resolved.error || "", display: [] };
|
|
185
|
+
if (resolved.method !== "get") {
|
|
186
|
+
// 검증이 부작용을 내면 안 된다 — 읽기 전용 강제
|
|
187
|
+
return { ok: false, reasonCode: "verify_not_readonly", display: [] };
|
|
188
|
+
}
|
|
189
|
+
let httpStatus = 0; let body;
|
|
190
|
+
try {
|
|
191
|
+
const res = await adminApi.request({ method: resolved.method, url: resolved.url,
|
|
192
|
+
path: resolved.path, query: resolved.query, data: resolved.data });
|
|
193
|
+
({ httpStatus, body } = normalizeResponse(res));
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const resp = err && err.response;
|
|
196
|
+
if (resp && typeof resp.status === "number") { httpStatus = resp.status; body = resp.data; }
|
|
197
|
+
else return { ok: false, reasonCode: "verify_unavailable", message: "transport", display: [] };
|
|
198
|
+
}
|
|
199
|
+
if (httpStatus < 200 || httpStatus >= 300) {
|
|
200
|
+
return { ok: false, reasonCode: "verify_unavailable", message: `HTTP ${httpStatus}`, httpStatus, display: [] };
|
|
201
|
+
}
|
|
202
|
+
const display = (Array.isArray(v.display_fields) ? v.display_fields : []).map((f) => {
|
|
203
|
+
const val = pluck(body, f && f.path);
|
|
204
|
+
return { label: String((f && (f.label || f.path)) || ""),
|
|
205
|
+
value: val === undefined || val === null ? "—" : String(val) };
|
|
206
|
+
});
|
|
207
|
+
const pre = evaluatePreconditions(v.preconditions, body, offer && offer.params_bound);
|
|
208
|
+
if (pre.blocked) return { ok: false, reasonCode: "precondition_failed", denyCopy: pre.denyCopy, display, httpStatus };
|
|
209
|
+
return { ok: true, display, httpStatus };
|
|
210
|
+
}
|
|
211
|
+
|
|
136
212
|
/**
|
|
137
213
|
* CMS API 1회 호출 + 판정. **재시도 0.** adminApi.request({method,url,path,query,data}) 는
|
|
138
214
|
* CMS 의 기존 헬퍼(예: TempAdminApi.request) 참조 — 세션 인증·화이트리스트·진행바는
|
package/element.js
CHANGED
|
@@ -204,10 +204,19 @@ function createActionsClass() {
|
|
|
204
204
|
this.replaceChildren();
|
|
205
205
|
if (!this.#agent || !this.#session) return;
|
|
206
206
|
const tree = actionOffersTree({ offers: this.#offers, pending: this.#pending,
|
|
207
|
-
enabled: Boolean(this.#agent.actionsEnabled), confirming: this.#confirming,
|
|
207
|
+
enabled: Boolean(this.#agent.actionsEnabled), confirming: this.#confirming,
|
|
208
|
+
verifies: typeof this.#session.actionVerifies === "function" ? this.#session.actionVerifies() : {},
|
|
209
|
+
messages: this.#agent.messages });
|
|
208
210
|
if (!tree) return;
|
|
209
211
|
const actions = {
|
|
210
|
-
confirm: (e) => {
|
|
212
|
+
confirm: (e) => {
|
|
213
|
+
const id = e.currentTarget.dataset.offer || null;
|
|
214
|
+
this.#confirming = id; this.#ackedOffer = null;
|
|
215
|
+
// 대상 확정 (T2.7) — 패널이 열리면 읽기 확인을 시동한다 (결과는 notify 로 재렌더)
|
|
216
|
+
const offer = this.#offers.find((o) => o.offer_id === id);
|
|
217
|
+
if (offer && offer.verify && typeof this.#session.verifyAction === "function") this.#session.verifyAction(offer);
|
|
218
|
+
this.#render();
|
|
219
|
+
},
|
|
211
220
|
cancel: () => { this.#confirming = null; this.#ackedOffer = null; this.#render(); },
|
|
212
221
|
ack: (e) => { const id = e.currentTarget.dataset.offer; this.#ackedOffer = this.#ackedOffer === id ? null : id; this.#render(); },
|
|
213
222
|
execute: (e) => this.#execute(e.currentTarget.dataset.offer),
|
package/locales.js
CHANGED
|
@@ -48,6 +48,10 @@ const en = {
|
|
|
48
48
|
ui_action_risk_low: "low risk",
|
|
49
49
|
ui_action_risk_medium: "medium risk",
|
|
50
50
|
ui_action_risk_high: "high risk",
|
|
51
|
+
ui_action_verify_title: "Confirm the target before running",
|
|
52
|
+
ui_action_verify_loading: "Checking the target…",
|
|
53
|
+
ui_action_verify_blocked: "Blocked by a pre-run check.",
|
|
54
|
+
ui_action_verify_failed: "Could not confirm the target — this action will not run.",
|
|
51
55
|
ui_overwrite_confirm: "Replace your current draft with the AI draft?",
|
|
52
56
|
// 거절·오류 사유
|
|
53
57
|
not_configured: "AI reply suggestions are not connected yet.",
|
|
@@ -91,6 +95,10 @@ const ko = {
|
|
|
91
95
|
ui_action_risk_low: "위험 낮음",
|
|
92
96
|
ui_action_risk_medium: "위험 보통",
|
|
93
97
|
ui_action_risk_high: "위험 높음",
|
|
98
|
+
ui_action_verify_title: "실행 전 대상 확인",
|
|
99
|
+
ui_action_verify_loading: "대상 확인 중…",
|
|
100
|
+
ui_action_verify_blocked: "실행 전 확인에서 차단되었습니다.",
|
|
101
|
+
ui_action_verify_failed: "대상을 확인할 수 없습니다 — 실행하지 않습니다.",
|
|
94
102
|
ui_overwrite_confirm: "작성 중인 답변을 지우고 AI 초안으로 바꿀까요?",
|
|
95
103
|
not_configured: "AI 답변 제안이 아직 연결되지 않았습니다.",
|
|
96
104
|
unsupported_api: "AI 답변 제안이 아직 연결되지 않았습니다.",
|
|
@@ -129,6 +137,10 @@ const ja = {
|
|
|
129
137
|
ui_action_risk_low: "リスク低",
|
|
130
138
|
ui_action_risk_medium: "リスク中",
|
|
131
139
|
ui_action_risk_high: "リスク高",
|
|
140
|
+
ui_action_verify_title: "実行前に対象を確認",
|
|
141
|
+
ui_action_verify_loading: "対象を確認しています…",
|
|
142
|
+
ui_action_verify_blocked: "実行前チェックでブロックされました。",
|
|
143
|
+
ui_action_verify_failed: "対象を確認できません — 実行しません。",
|
|
132
144
|
ui_overwrite_confirm: "作成中の回答を消してAI下書きに置き換えますか?",
|
|
133
145
|
not_configured: "AI 返信案はまだ接続されていません。",
|
|
134
146
|
unsupported_api: "AI 返信案はまだ接続されていません。",
|
|
@@ -167,6 +179,10 @@ const zhTW = {
|
|
|
167
179
|
ui_action_risk_low: "風險低",
|
|
168
180
|
ui_action_risk_medium: "風險中",
|
|
169
181
|
ui_action_risk_high: "風險高",
|
|
182
|
+
ui_action_verify_title: "執行前確認對象",
|
|
183
|
+
ui_action_verify_loading: "正在確認對象…",
|
|
184
|
+
ui_action_verify_blocked: "已被執行前檢查擋下。",
|
|
185
|
+
ui_action_verify_failed: "無法確認對象 — 不會執行。",
|
|
170
186
|
ui_overwrite_confirm: "要清除目前草稿並以 AI 草稿取代嗎?",
|
|
171
187
|
not_configured: "AI 回覆建議尚未連接。",
|
|
172
188
|
unsupported_api: "AI 回覆建議尚未連接。",
|
package/package.json
CHANGED
package/react.js
CHANGED
|
@@ -144,11 +144,18 @@ export function ActionOffersPanel({ session, agent, actorClaimed, onResult, clas
|
|
|
144
144
|
}, [offers, ackedOffer, session, onResult]);
|
|
145
145
|
|
|
146
146
|
if (!session || !agent) return null;
|
|
147
|
-
const tree = actionOffersTree({ offers, pending, enabled: Boolean(agent.actionsEnabled), confirming,
|
|
147
|
+
const tree = actionOffersTree({ offers, pending, enabled: Boolean(agent.actionsEnabled), confirming,
|
|
148
|
+
verifies: typeof session.actionVerifies === "function" ? session.actionVerifies() : {},
|
|
149
|
+
messages: agent.messages });
|
|
148
150
|
if (!tree) return null;
|
|
149
151
|
|
|
150
152
|
const actions = {
|
|
151
|
-
confirm: (e) => {
|
|
153
|
+
confirm: (e) => {
|
|
154
|
+
const id = e.currentTarget.dataset.offer || null;
|
|
155
|
+
setConfirming(id); setAckedOffer(null);
|
|
156
|
+
const offer = offers.find((o) => o.offer_id === id);
|
|
157
|
+
if (offer && offer.verify && typeof session.verifyAction === "function") session.verifyAction(offer); // T2.7 — 결과는 notify 재렌더
|
|
158
|
+
},
|
|
152
159
|
cancel: () => { setConfirming(null); setAckedOffer(null); },
|
|
153
160
|
ack: (e) => { const id = e.currentTarget.dataset.offer; setAckedOffer((v) => (v === id ? null : id)); },
|
|
154
161
|
execute: (e) => execute(e.currentTarget.dataset.offer),
|
package/session.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* **수명이 다르다**: 요청은 전송 **전에** 남고, 종단 뒤 24h 까지 살아 새로고침·
|
|
25
25
|
* 재진입 시 "이미 눌렀던 것" 을 안다. 정본은 허브 원장이다 — 브라우저 소실은
|
|
26
26
|
* 기록 손실이 아니라 표시 손실일 뿐. localStorage 부재면 메모리로만 산다. */
|
|
27
|
-
import { executeViaAdminApi } from "./actions.js";
|
|
27
|
+
import { executeViaAdminApi, verifyViaAdminApi } from "./actions.js";
|
|
28
28
|
|
|
29
29
|
export class ActionLedger {
|
|
30
30
|
constructor({ impl, prefix = "cx-agent-actions", terminalTtlMs = 24 * 60 * 60 * 1000 } = {}) {
|
|
@@ -156,6 +156,7 @@ export class InquirySession {
|
|
|
156
156
|
this._offers = []; // 마지막 답변의 처리 선택지 (actions/1 §4)
|
|
157
157
|
this._actionUi = null;
|
|
158
158
|
this._locks = {}; // action_key\x00target_key → 잠금 조회 결과 (세션 캐시)
|
|
159
|
+
this._verify = {}; // offer_id → 대상 확정(더블체크) 결과 (T2.7, 화면 표시용 캐시)
|
|
159
160
|
|
|
160
161
|
// 영속 복원 — TTL 만료분은 store 가 이미 걸렀다
|
|
161
162
|
if (this._id && deps.store) {
|
|
@@ -263,6 +264,27 @@ export class InquirySession {
|
|
|
263
264
|
return { requestId, ...r };
|
|
264
265
|
}
|
|
265
266
|
if (d.actionLedger) d.actionLedger.put(requestId, { ...rec, state: "received" });
|
|
267
|
+
// ②-a 대상 확정 (T2.7, 계약 §B.10) — verify 동봉 offer 는 **쓰기 직전에 읽기 재검**.
|
|
268
|
+
// 패널의 verifyAction 표시와 별개로 여기서 다시 읽는다 (TOCTOU 최소화 — 읽기
|
|
269
|
+
// 직후 즉시 쓰기). 읽기 실패·precondition 불통과 = 실행 차단 + 원장 보고.
|
|
270
|
+
if (offer.verify) {
|
|
271
|
+
const vr = await verifyViaAdminApi(d.adminApi, offer);
|
|
272
|
+
this._verify[offer.offer_id] = { state: vr.ok ? "ok" : "blocked", reasonCode: vr.reasonCode || "",
|
|
273
|
+
denyCopy: vr.denyCopy || "", message: vr.message || "", display: vr.display || [] };
|
|
274
|
+
if (!vr.ok) {
|
|
275
|
+
const reasonCode = vr.reasonCode || "verify_unavailable";
|
|
276
|
+
const message = vr.denyCopy || vr.message || "";
|
|
277
|
+
const report = { state: "failed", reasonCode, message, httpStatus: vr.httpStatus || 0, latencyMs: 0 };
|
|
278
|
+
const rep = await d.client.reportActionResult(requestId, report);
|
|
279
|
+
const unreported = !rep.ok && rep.httpStatus === 0;
|
|
280
|
+
if (d.actionLedger) {
|
|
281
|
+
d.actionLedger.put(requestId, { ...rec, state: "failed", reasonCode, message,
|
|
282
|
+
unreported, pendingReport: unreported ? report : undefined });
|
|
283
|
+
}
|
|
284
|
+
this._notifyActions();
|
|
285
|
+
return { requestId, ok: true, state: "failed", reasonCode, message, reported: !unreported, error: null };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
266
288
|
// ② CMS 자기 API — 재시도 0
|
|
267
289
|
const out = await executeViaAdminApi(d.adminApi, offer);
|
|
268
290
|
if (out.authExpired && typeof d.adminApi.onAuthExpired === "function") {
|
|
@@ -285,6 +307,25 @@ export class InquirySession {
|
|
|
285
307
|
return { requestId, ok: true, state: out.state, reasonCode: out.reasonCode, message: out.message,
|
|
286
308
|
reported: !unreported, error: null };
|
|
287
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* 대상 확정 미리보기 (T2.7) — 확인 패널이 열릴 때 UI 가 부른다. 결과는 캐시돼
|
|
312
|
+
* 카드의 "대상 확인" 블록에 표시되고, 실행 버튼은 ok 일 때만 나온다.
|
|
313
|
+
* 실제 실행(executeAction)은 이 캐시를 믿지 않고 쓰기 직전에 다시 읽는다.
|
|
314
|
+
*/
|
|
315
|
+
async verifyAction(offer) {
|
|
316
|
+
const d = this._d;
|
|
317
|
+
if (!offer || !offer.offer_id || !offer.verify) return { ok: true, skipped: true, display: [] };
|
|
318
|
+
if (!d.adminApi || typeof d.adminApi.request !== "function") return { ok: false, reasonCode: "admin_api_missing", display: [] };
|
|
319
|
+
this._verify[offer.offer_id] = { state: "loading", reasonCode: "", denyCopy: "", message: "", display: [] };
|
|
320
|
+
this._notifyActions();
|
|
321
|
+
const out = await verifyViaAdminApi(d.adminApi, offer);
|
|
322
|
+
this._verify[offer.offer_id] = { state: out.ok ? "ok" : "blocked", reasonCode: out.reasonCode || "",
|
|
323
|
+
denyCopy: out.denyCopy || "", message: out.message || "", display: out.display || [] };
|
|
324
|
+
this._notifyActions();
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
/** offer_id → 대상 확정 상태 스냅샷 — view 의 `verifies` 입력. */
|
|
328
|
+
actionVerifies() { return { ...this._verify }; }
|
|
288
329
|
/** 미보고 결과 재보고 — 재실행이 아니다. ledger 의 pendingReport 만 다시 보낸다. */
|
|
289
330
|
async resendResult(requestId) {
|
|
290
331
|
const d = this._d;
|
package/styles.css
CHANGED
|
@@ -139,6 +139,15 @@
|
|
|
139
139
|
background: var(--fcx-surface-2, #e8eef7); border-radius: var(--fcx-radius, 10px);
|
|
140
140
|
}
|
|
141
141
|
.fcx-act-confirm-text { font-weight: 600; }
|
|
142
|
+
/* 대상 확인 블록 (T2.7 §B.10) — 실행 전 읽기 검증 표시 */
|
|
143
|
+
.fcx-act-verify {
|
|
144
|
+
display: grid; gap: 2px; padding: 6px 8px;
|
|
145
|
+
border: 1px dashed var(--fcx-accent, #2f6fed); border-radius: 6px;
|
|
146
|
+
background: var(--fcx-surface, #fff);
|
|
147
|
+
}
|
|
148
|
+
.fcx-act-verify-title { font-weight: 600; font-size: 0.92em; }
|
|
149
|
+
.fcx-act-verify-line { font-variant-numeric: tabular-nums; }
|
|
150
|
+
.fcx-act-verify-status { color: var(--fcx-warn, #6b4a0b); font-size: 0.92em; }
|
|
142
151
|
.fcx-act-ack {
|
|
143
152
|
justify-self: start; cursor: pointer; font: inherit;
|
|
144
153
|
padding: 2px 4px; border: 0; background: transparent; border-radius: 4px;
|
|
@@ -151,6 +160,8 @@
|
|
|
151
160
|
.fcx-act-status[data-state="failed"] { color: var(--fcx-danger, #b42318); }
|
|
152
161
|
.fcx-act-status[data-state="unknown"] { color: var(--fcx-warn, #9a6a12); }
|
|
153
162
|
.fcx-act-status[data-state="unavailable"] { color: var(--fcx-muted, #5b6b7d); font-style: italic; }
|
|
163
|
+
.fcx-act-success-copy { margin-top: 4px; padding: 6px 8px; font-size: 13px; border-radius: 6px;
|
|
164
|
+
background: var(--fcx-success-bg, #e8f5ec); color: var(--fcx-success, #1f7a3f); }
|
|
154
165
|
.fcx-act-locked {
|
|
155
166
|
color: var(--fcx-success, #1f7a3f);
|
|
156
167
|
padding: 4px 8px; border-radius: 6px;
|
|
@@ -161,6 +172,7 @@
|
|
|
161
172
|
[data-fcx-theme="dark"] .fcx-act { color: var(--fcx-text, #dbe6f3); }
|
|
162
173
|
[data-fcx-theme="dark"] .fcx-act-params, [data-fcx-theme="dark"] .fcx-act-status { color: var(--fcx-muted, #8ba0b8); }
|
|
163
174
|
[data-fcx-theme="dark"] .fcx-act-risk, [data-fcx-theme="dark"] .fcx-act-confirm { background: var(--fcx-surface-2, #182238); }
|
|
175
|
+
[data-fcx-theme="dark"] .fcx-act-verify { background: var(--fcx-surface, #0f1626); }
|
|
164
176
|
[data-fcx-theme="dark"] .fcx-act-locked { background: var(--fcx-success-bg, #12301c); }
|
|
165
177
|
|
|
166
178
|
@media (prefers-reduced-motion: reduce) {
|
package/view.js
CHANGED
|
@@ -126,10 +126,15 @@ export const ACTION_CLS = {
|
|
|
126
126
|
button: "fcx-act-button",
|
|
127
127
|
confirm: "fcx-act-confirm",
|
|
128
128
|
confirmText: "fcx-act-confirm-text",
|
|
129
|
+
verify: "fcx-act-verify",
|
|
130
|
+
verifyTitle: "fcx-act-verify-title",
|
|
131
|
+
verifyLine: "fcx-act-verify-line",
|
|
132
|
+
verifyStatus: "fcx-act-verify-status",
|
|
129
133
|
ack: "fcx-act-ack",
|
|
130
134
|
status: "fcx-act-status",
|
|
131
135
|
locked: "fcx-act-locked",
|
|
132
136
|
resend: "fcx-act-resend",
|
|
137
|
+
successCopy: "fcx-act-success-copy",
|
|
133
138
|
};
|
|
134
139
|
|
|
135
140
|
const RISK_KEY = { low: "ui_action_risk_low", medium: "ui_action_risk_medium", high: "ui_action_risk_high" };
|
|
@@ -147,9 +152,10 @@ function fmtWhen(iso) {
|
|
|
147
152
|
* @param {Array} pending session.pendingActions() [{requestId, offerId, state, reasonCode, message, label, unreported}]
|
|
148
153
|
* @param {boolean} enabled agent.actionsEnabled
|
|
149
154
|
* @param {string|null} confirming 확인 패널이 열린 offer_id
|
|
155
|
+
* @param {Record<string,object>} verifies session.actionVerifies() — offer_id → 대상 확정 상태 (T2.7)
|
|
150
156
|
* @param {Record<string,string>} messages
|
|
151
157
|
*/
|
|
152
|
-
export function actionOffersView({ offers = [], pending = [], enabled = true, confirming = null, messages = {} }) {
|
|
158
|
+
export function actionOffersView({ offers = [], pending = [], enabled = true, confirming = null, verifies = {}, messages = {} }) {
|
|
153
159
|
const byOffer = new Map();
|
|
154
160
|
for (const p of pending) byOffer.set(p.offerId, p);
|
|
155
161
|
const items = offers.map((o) => {
|
|
@@ -164,6 +170,19 @@ export function actionOffersView({ offers = [], pending = [], enabled = true, co
|
|
|
164
170
|
else if (state === "failed") statusText = `${messages.ui_action_failed}${p && p.message ? " — " + p.message : ""}`;
|
|
165
171
|
else if (state === "unknown") statusText = messages.ui_action_unknown;
|
|
166
172
|
if (p && p.unreported) statusText = `${statusText} · ${messages.ui_action_unreported}`;
|
|
173
|
+
// success_copy 는 실행이 실제로 성공한 뒤에만 — 절차 자산의 "실행 성공 후 문구"
|
|
174
|
+
// (실행 전 카드·답변에 실으면 완료 사칭이 된다. 서버도 같은 이유로 승인 게이트를 둔다.)
|
|
175
|
+
const successCopy = state === "succeeded" ? String(o.success_copy || "") : "";
|
|
176
|
+
const needsVerify = Boolean(o.verify);
|
|
177
|
+
const vr = needsVerify ? (verifies[o.offer_id] || null) : null;
|
|
178
|
+
const verifyState = !needsVerify ? "" : (vr ? vr.state : "none");
|
|
179
|
+
let verifyText = "";
|
|
180
|
+
if (needsVerify && (verifyState === "none" || verifyState === "loading")) {
|
|
181
|
+
verifyText = messages.ui_action_verify_loading;
|
|
182
|
+
} else if (verifyState === "blocked") {
|
|
183
|
+
verifyText = (vr && vr.denyCopy)
|
|
184
|
+
|| (vr && vr.reasonCode === "precondition_failed" ? messages.ui_action_verify_blocked : messages.ui_action_verify_failed);
|
|
185
|
+
}
|
|
167
186
|
let lockedText = "";
|
|
168
187
|
if (locked) {
|
|
169
188
|
const who = o.lock && o.lock.actorClaimed ? ` · ${o.lock.actorClaimed}` : "";
|
|
@@ -180,11 +199,17 @@ export function actionOffersView({ offers = [], pending = [], enabled = true, co
|
|
|
180
199
|
buttonLabel: messages.ui_action_execute,
|
|
181
200
|
confirming: confirming === o.offer_id,
|
|
182
201
|
confirmText: o.confirm || messages.ui_action_confirm_default,
|
|
202
|
+
// 대상 확정 (T2.7) — verify 동봉 offer 는 읽기 확인이 ok 여야 실행 버튼이 나온다 (위험도 무관)
|
|
203
|
+
needsVerify,
|
|
204
|
+
verifyOk: !needsVerify || verifyState === "ok",
|
|
205
|
+
verifyTitle: messages.ui_action_verify_title,
|
|
206
|
+
verifyLines: vr && Array.isArray(vr.display) ? vr.display.map((f) => `${f.label}: ${f.value}`) : [],
|
|
207
|
+
verifyText,
|
|
183
208
|
needsAck: o.risk_level === "high",
|
|
184
209
|
ackLabel: messages.ui_action_ack,
|
|
185
210
|
okLabel: messages.ui_action_confirm_ok,
|
|
186
211
|
cancelLabel: messages.ui_action_confirm_cancel,
|
|
187
|
-
state, statusText, terminal,
|
|
212
|
+
state, statusText, terminal, successCopy,
|
|
188
213
|
// 미보고만 "결과 다시 보고" — 재실행 버튼은 없다 (unknown 은 확인 필요로 남는다)
|
|
189
214
|
canResend: Boolean(p && p.unreported),
|
|
190
215
|
resendLabel: messages.ui_action_resend,
|
|
@@ -214,9 +239,17 @@ export function actionOffersTree(input) {
|
|
|
214
239
|
children.push({
|
|
215
240
|
tag: "div", cls: ACTION_CLS.confirm, children: [
|
|
216
241
|
{ tag: "div", cls: ACTION_CLS.confirmText, text: it.confirmText },
|
|
242
|
+
// 대상 확인 블록 (T2.7) — 읽기 결과를 상담사 눈으로 확인한 뒤에만 실행 버튼이 나온다
|
|
243
|
+
...(it.needsVerify ? [{
|
|
244
|
+
tag: "div", cls: ACTION_CLS.verify, children: [
|
|
245
|
+
{ tag: "div", cls: ACTION_CLS.verifyTitle, text: it.verifyTitle },
|
|
246
|
+
...it.verifyLines.map((t) => ({ tag: "div", cls: ACTION_CLS.verifyLine, text: t })),
|
|
247
|
+
...(it.verifyText ? [{ tag: "div", cls: ACTION_CLS.verifyStatus, text: it.verifyText, live: true }] : []),
|
|
248
|
+
],
|
|
249
|
+
}] : []),
|
|
217
250
|
// 고위험 2단 확인 — 토글 **버튼**(aria-pressed): 포커스·Space/Enter 가 되어야 키보드 사용자도 실행할 수 있다
|
|
218
251
|
...(it.needsAck ? [{ tag: "button", type: "button", cls: ACTION_CLS.ack, text: it.ackLabel, action: "ack", offerId: it.offerId, pressed: true }] : []),
|
|
219
|
-
{ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.okLabel, action: "execute", offerId: it.offerId },
|
|
252
|
+
...(it.verifyOk ? [{ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.okLabel, action: "execute", offerId: it.offerId }] : []),
|
|
220
253
|
{ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.cancelLabel, action: "cancel", offerId: it.offerId },
|
|
221
254
|
],
|
|
222
255
|
});
|
|
@@ -224,6 +257,7 @@ export function actionOffersTree(input) {
|
|
|
224
257
|
children.push({ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.buttonLabel, action: "confirm", offerId: it.offerId });
|
|
225
258
|
}
|
|
226
259
|
if (it.statusText) children.push({ tag: "div", cls: ACTION_CLS.status, text: it.statusText, state: it.state, live: true });
|
|
260
|
+
if (it.successCopy) children.push({ tag: "div", cls: ACTION_CLS.successCopy, text: it.successCopy });
|
|
227
261
|
if (it.canResend) {
|
|
228
262
|
children.push({ tag: "button", cls: ACTION_CLS.resend, type: "button", text: it.resendLabel, action: "resend", requestId: it.requestId });
|
|
229
263
|
}
|
package/vue.js
CHANGED
|
@@ -147,7 +147,12 @@ export const ActionOffersPanel = defineComponent({
|
|
|
147
147
|
emit("result", r);
|
|
148
148
|
};
|
|
149
149
|
const actions = {
|
|
150
|
-
confirm: (e) => {
|
|
150
|
+
confirm: (e) => {
|
|
151
|
+
const id = e.currentTarget.dataset.offer || null;
|
|
152
|
+
confirming.value = id; ackedOffer.value = null;
|
|
153
|
+
const offer = offers.value.find((o) => o.offer_id === id);
|
|
154
|
+
if (offer && offer.verify && typeof props.session.verifyAction === "function") props.session.verifyAction(offer); // T2.7
|
|
155
|
+
},
|
|
151
156
|
cancel: () => { confirming.value = null; ackedOffer.value = null; },
|
|
152
157
|
ack: (e) => { const id = e.currentTarget.dataset.offer; ackedOffer.value = ackedOffer.value === id ? null : id; },
|
|
153
158
|
execute: (e) => execute(e.currentTarget.dataset.offer),
|
|
@@ -155,7 +160,9 @@ export const ActionOffersPanel = defineComponent({
|
|
|
155
160
|
};
|
|
156
161
|
const tree = computed(() => actionOffersTree({
|
|
157
162
|
offers: offers.value, pending: pending.value, enabled: Boolean(props.agent && props.agent.actionsEnabled),
|
|
158
|
-
confirming: confirming.value,
|
|
163
|
+
confirming: confirming.value,
|
|
164
|
+
verifies: props.session && typeof props.session.actionVerifies === "function" ? props.session.actionVerifies() : {},
|
|
165
|
+
messages: props.agent ? props.agent.messages : {},
|
|
159
166
|
}));
|
|
160
167
|
const render = (node) => {
|
|
161
168
|
const p = { class: node.cls };
|