@fcg-labs/cx-agent-hook 0.3.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/AGENTS.md +14 -0
- package/MIGRATION.md +82 -0
- package/README.md +67 -0
- package/actions.js +168 -0
- package/agent.d.ts +128 -1
- package/agent.js +56 -3
- package/angular.d.ts +2 -2
- package/angular.js +1 -1
- package/astro.d.ts +2 -2
- package/astro.js +1 -0
- package/bin/cx-agent-hook.js +78 -0
- package/client.js +166 -0
- package/element.d.ts +12 -1
- package/element.js +124 -1
- package/locales.js +68 -0
- package/next.d.ts +2 -2
- package/next.js +1 -1
- package/package.json +11 -4
- package/react.d.ts +14 -0
- package/react.js +80 -1
- package/session.js +192 -0
- package/skills/cx-agent-hook-setup/SKILL.md +195 -0
- package/skills/cx-agent-hook-setup/reference.md +92 -0
- package/styles.css +65 -1
- package/view.d.ts +29 -2
- package/view.js +129 -0
- package/vue.d.ts +10 -0
- package/vue.js +80 -2
package/styles.css
CHANGED
|
@@ -99,6 +99,70 @@
|
|
|
99
99
|
}
|
|
100
100
|
[data-fcx-theme="dark"] .fcx-ai-declined { color: var(--fcx-warn, #d9a94e); }
|
|
101
101
|
|
|
102
|
+
/* ── 처리 선택지 카드 (actions/1 프로파일 B) — 답변 패널과 같은 토큰 계약 ────────
|
|
103
|
+
* 상태는 클래스+data-state 로만 말한다: 잠김(.fcx-act-locked)·비활성(data-state=unavailable)·
|
|
104
|
+
* 실행 중/완료/실패/확인 필요(.fcx-act-status[data-state])·미보고(.fcx-act-resend). */
|
|
105
|
+
.fcx-act { display: grid; gap: var(--fcx-gap, 8px); font-size: var(--fcx-font-size, 13px); color: var(--fcx-text, #1f2d3d); }
|
|
106
|
+
.fcx-act-item {
|
|
107
|
+
display: grid; gap: 6px;
|
|
108
|
+
padding: var(--fcx-pad, 10px 12px);
|
|
109
|
+
border: 1px solid var(--fcx-border, #d7e3f4);
|
|
110
|
+
border-radius: var(--fcx-radius, 10px);
|
|
111
|
+
background: var(--fcx-surface, #f5f9ff);
|
|
112
|
+
}
|
|
113
|
+
.fcx-act-label { font-weight: 600; }
|
|
114
|
+
.fcx-act-params { color: var(--fcx-muted, #5b6b7d); }
|
|
115
|
+
.fcx-act-risk {
|
|
116
|
+
justify-self: start; padding: 1px 8px; border-radius: 999px;
|
|
117
|
+
font-size: calc(var(--fcx-font-size, 13px) - 1px);
|
|
118
|
+
background: var(--fcx-surface-2, #e8eef7); color: var(--fcx-muted, #5b6b7d);
|
|
119
|
+
}
|
|
120
|
+
.fcx-act-risk[data-risk="high"] { background: var(--fcx-warn-bg, #fbf1de); color: var(--fcx-warn, #9a6a12); }
|
|
121
|
+
.fcx-act-button, .fcx-act-resend {
|
|
122
|
+
justify-self: start;
|
|
123
|
+
padding: var(--fcx-button-pad, 6px 12px);
|
|
124
|
+
border: 1px solid var(--fcx-accent, #2f6fed);
|
|
125
|
+
border-radius: var(--fcx-button-radius, 8px);
|
|
126
|
+
background: var(--fcx-accent, #2f6fed); color: var(--fcx-on-accent, #fff);
|
|
127
|
+
font: inherit; cursor: pointer;
|
|
128
|
+
transition: background-color 120ms ease-out, opacity 120ms ease-out;
|
|
129
|
+
}
|
|
130
|
+
.fcx-act-button:hover:not(:disabled) { background: var(--fcx-accent-hover, #245ad0); }
|
|
131
|
+
.fcx-act-button:disabled { opacity: var(--fcx-disabled-opacity, 0.6); cursor: not-allowed; }
|
|
132
|
+
.fcx-act-resend { background: transparent; color: var(--fcx-accent, #2f6fed); }
|
|
133
|
+
.fcx-act-button:focus-visible, .fcx-act-resend:focus-visible, .fcx-act-ack:focus-visible {
|
|
134
|
+
outline: 2px solid var(--fcx-accent, #2f6fed); outline-offset: 2px;
|
|
135
|
+
}
|
|
136
|
+
.fcx-act-confirm {
|
|
137
|
+
display: grid; gap: 6px; padding: 8px 10px;
|
|
138
|
+
border-left: 3px solid var(--fcx-accent, #2f6fed);
|
|
139
|
+
background: var(--fcx-surface-2, #e8eef7); border-radius: var(--fcx-radius, 10px);
|
|
140
|
+
}
|
|
141
|
+
.fcx-act-confirm-text { font-weight: 600; }
|
|
142
|
+
.fcx-act-ack {
|
|
143
|
+
justify-self: start; cursor: pointer; font: inherit;
|
|
144
|
+
padding: 2px 4px; border: 0; background: transparent; border-radius: 4px;
|
|
145
|
+
color: var(--fcx-warn, #6b4a0b); /* 확인 패널 배경(#e8eef7) 위 대비 ≥ 5:1 */
|
|
146
|
+
}
|
|
147
|
+
.fcx-act-ack::before { content: "☐ "; }
|
|
148
|
+
.fcx-act-ack[aria-pressed="true"]::before { content: "☑ "; }
|
|
149
|
+
.fcx-act-status { color: var(--fcx-muted, #5b6b7d); }
|
|
150
|
+
.fcx-act-status[data-state="succeeded"] { color: var(--fcx-success, #1f7a3f); }
|
|
151
|
+
.fcx-act-status[data-state="failed"] { color: var(--fcx-danger, #b42318); }
|
|
152
|
+
.fcx-act-status[data-state="unknown"] { color: var(--fcx-warn, #9a6a12); }
|
|
153
|
+
.fcx-act-status[data-state="unavailable"] { color: var(--fcx-muted, #5b6b7d); font-style: italic; }
|
|
154
|
+
.fcx-act-locked {
|
|
155
|
+
color: var(--fcx-success, #1f7a3f);
|
|
156
|
+
padding: 4px 8px; border-radius: 6px;
|
|
157
|
+
background: var(--fcx-success-bg, #e6f4ea);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
[data-fcx-theme="dark"] .fcx-act-item { background: var(--fcx-surface, #10182a); border-color: var(--fcx-border, #24344d); }
|
|
161
|
+
[data-fcx-theme="dark"] .fcx-act { color: var(--fcx-text, #dbe6f3); }
|
|
162
|
+
[data-fcx-theme="dark"] .fcx-act-params, [data-fcx-theme="dark"] .fcx-act-status { color: var(--fcx-muted, #8ba0b8); }
|
|
163
|
+
[data-fcx-theme="dark"] .fcx-act-risk, [data-fcx-theme="dark"] .fcx-act-confirm { background: var(--fcx-surface-2, #182238); }
|
|
164
|
+
[data-fcx-theme="dark"] .fcx-act-locked { background: var(--fcx-success-bg, #12301c); }
|
|
165
|
+
|
|
102
166
|
@media (prefers-reduced-motion: reduce) {
|
|
103
|
-
.fcx-ai-button, .fcx-ai-adopt { transition: none; }
|
|
167
|
+
.fcx-ai-button, .fcx-ai-adopt, .fcx-act-button, .fcx-act-resend { transition: none; }
|
|
104
168
|
}
|
package/view.d.ts
CHANGED
|
@@ -32,13 +32,40 @@ export interface AiSuggestNode {
|
|
|
32
32
|
cls: string;
|
|
33
33
|
text?: string;
|
|
34
34
|
children?: AiSuggestNode[];
|
|
35
|
-
/** 추상 동작명 — 어댑터가 자기 이벤트 체계로
|
|
36
|
-
|
|
35
|
+
/** 추상 동작명 — 어댑터가 자기 이벤트 체계로 배선. 답변 패널: "request" | "adopt" ·
|
|
36
|
+
* 처리 카드: "confirm" | "execute" | "cancel" | "ack" | "resend" */
|
|
37
|
+
action?: "request" | "adopt" | "confirm" | "execute" | "cancel" | "ack" | "resend";
|
|
37
38
|
type?: string;
|
|
38
39
|
disabled?: boolean;
|
|
40
|
+
/** 처리 카드 노드 부가 필드 (actionOffersTree) */
|
|
41
|
+
offerId?: string; requestId?: string; risk?: string; state?: string;
|
|
42
|
+
/** 상태 문구 = role="status" (live) · ack 토글 버튼 = aria-pressed (pressed) */
|
|
43
|
+
live?: boolean; pressed?: boolean;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/** 패널 전체 구조 스펙 — headless(UI 0) 소비처의 진입점이기도 하다 */
|
|
42
47
|
export declare function aiSuggestTree(
|
|
43
48
|
input: Parameters<typeof aiSuggestView>[0],
|
|
44
49
|
): AiSuggestNode;
|
|
50
|
+
|
|
51
|
+
// ── 처리 액션 (actions/1) 뷰 ──
|
|
52
|
+
export declare const ACTION_CLS: Record<
|
|
53
|
+
"root" | "item" | "label" | "params" | "risk" | "button" | "confirm" | "confirmText" | "ack" | "status" | "locked" | "resend",
|
|
54
|
+
string
|
|
55
|
+
>;
|
|
56
|
+
export interface ActionOfferItemView {
|
|
57
|
+
offerId: string; label: string; params: string; riskLabel: string; risk: string;
|
|
58
|
+
/** 이 CMS 가 API 를 아는가 / 같은 대상·입력 최근 성공(카드 잠금) */
|
|
59
|
+
available: boolean; locked: boolean; lockedText: string; unavailableText: string;
|
|
60
|
+
canExecute: boolean; buttonLabel: string; confirming: boolean; confirmText: string;
|
|
61
|
+
needsAck: boolean; ackLabel: string; okLabel: string; cancelLabel: string;
|
|
62
|
+
state: string; statusText: string; terminal: boolean;
|
|
63
|
+
/** 미보고 결과 재보고 (재실행 아님) */
|
|
64
|
+
canResend: boolean; resendLabel: string; requestId: string;
|
|
65
|
+
}
|
|
66
|
+
export declare function actionOffersView(input: {
|
|
67
|
+
offers?: unknown[]; pending?: unknown[]; enabled?: boolean; confirming?: string | null;
|
|
68
|
+
messages?: Record<string, string>;
|
|
69
|
+
}): { visible: boolean; items: ActionOfferItemView[]; disabledHint: string };
|
|
70
|
+
/** action: "confirm" | "execute" | "cancel" | "resend" | "ack" — 텍스트 노드만 */
|
|
71
|
+
export declare function actionOffersTree(input: Parameters<typeof actionOffersView>[0]): AiSuggestNode | null;
|
package/view.js
CHANGED
|
@@ -103,3 +103,132 @@ export function aiSuggestTree(input) {
|
|
|
103
103
|
}
|
|
104
104
|
return { tag: "div", cls: CLS.root, children };
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
// ── 처리 액션 (actions/1 프로파일 B) — 선택지·확인·상태·잠금의 표시 판단 ─────
|
|
109
|
+
//
|
|
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 실행은 끝났으나 허브 보고가 안 됨 — "결과 다시 보고" (재실행 아님)
|
|
119
|
+
|
|
120
|
+
export const ACTION_CLS = {
|
|
121
|
+
root: "fcx-act",
|
|
122
|
+
item: "fcx-act-item",
|
|
123
|
+
label: "fcx-act-label",
|
|
124
|
+
params: "fcx-act-params",
|
|
125
|
+
risk: "fcx-act-risk",
|
|
126
|
+
button: "fcx-act-button",
|
|
127
|
+
confirm: "fcx-act-confirm",
|
|
128
|
+
confirmText: "fcx-act-confirm-text",
|
|
129
|
+
ack: "fcx-act-ack",
|
|
130
|
+
status: "fcx-act-status",
|
|
131
|
+
locked: "fcx-act-locked",
|
|
132
|
+
resend: "fcx-act-resend",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const RISK_KEY = { low: "ui_action_risk_low", medium: "ui_action_risk_medium", high: "ui_action_risk_high" };
|
|
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
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {Array} offers session.offers (available·locked·lock 병합됨)
|
|
147
|
+
* @param {Array} pending session.pendingActions() [{requestId, offerId, state, reasonCode, message, label, unreported}]
|
|
148
|
+
* @param {boolean} enabled agent.actionsEnabled
|
|
149
|
+
* @param {string|null} confirming 확인 패널이 열린 offer_id
|
|
150
|
+
* @param {Record<string,string>} messages
|
|
151
|
+
*/
|
|
152
|
+
export function actionOffersView({ offers = [], pending = [], enabled = true, confirming = null, messages = {} }) {
|
|
153
|
+
const byOffer = new Map();
|
|
154
|
+
for (const p of pending) byOffer.set(p.offerId, p);
|
|
155
|
+
const items = offers.map((o) => {
|
|
156
|
+
const p = byOffer.get(o.offer_id) || null;
|
|
157
|
+
const state = p ? p.state : "";
|
|
158
|
+
const terminal = state === "succeeded" || state === "failed";
|
|
159
|
+
const available = o.available !== false;
|
|
160
|
+
const locked = Boolean(o.locked) && !p;
|
|
161
|
+
let statusText = "";
|
|
162
|
+
if (state === "pending" || state === "received") statusText = messages.ui_action_running;
|
|
163
|
+
else if (state === "succeeded") statusText = messages.ui_action_succeeded;
|
|
164
|
+
else if (state === "failed") statusText = `${messages.ui_action_failed}${p && p.message ? " — " + p.message : ""}`;
|
|
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
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
offerId: o.offer_id, label: o.label || o.action_key, params: o.params_display || "",
|
|
174
|
+
riskLabel: messages[RISK_KEY[o.risk_level] || RISK_KEY.low] || "",
|
|
175
|
+
risk: o.risk_level || "low",
|
|
176
|
+
available, locked, lockedText,
|
|
177
|
+
unavailableText: available ? "" : messages.ui_action_unavailable,
|
|
178
|
+
// 실행 버튼: 표면 열림 ∧ 이 CMS 가 API 를 앎 ∧ 잠기지 않음 ∧ 미실행(또는 실패-재시도 가능)
|
|
179
|
+
canExecute: enabled && available && !locked && (!p || state === "failed"),
|
|
180
|
+
buttonLabel: messages.ui_action_execute,
|
|
181
|
+
confirming: confirming === o.offer_id,
|
|
182
|
+
confirmText: o.confirm || messages.ui_action_confirm_default,
|
|
183
|
+
needsAck: o.risk_level === "high",
|
|
184
|
+
ackLabel: messages.ui_action_ack,
|
|
185
|
+
okLabel: messages.ui_action_confirm_ok,
|
|
186
|
+
cancelLabel: messages.ui_action_confirm_cancel,
|
|
187
|
+
state, statusText, terminal,
|
|
188
|
+
// 미보고만 "결과 다시 보고" — 재실행 버튼은 없다 (unknown 은 확인 필요로 남는다)
|
|
189
|
+
canResend: Boolean(p && p.unreported),
|
|
190
|
+
resendLabel: messages.ui_action_resend,
|
|
191
|
+
requestId: p ? p.requestId : "",
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
return { visible: items.length > 0, items, disabledHint: enabled ? "" : messages.ui_action_disabled };
|
|
195
|
+
}
|
|
196
|
+
|
|
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 로 채운다). */
|
|
200
|
+
export function actionOffersTree(input) {
|
|
201
|
+
const v = actionOffersView(input);
|
|
202
|
+
if (!v.visible) return null;
|
|
203
|
+
const items = v.items.map((it) => {
|
|
204
|
+
const children = [
|
|
205
|
+
{ tag: "div", cls: ACTION_CLS.label, text: it.label },
|
|
206
|
+
...(it.params ? [{ tag: "div", cls: ACTION_CLS.params, text: it.params }] : []),
|
|
207
|
+
{ tag: "span", cls: ACTION_CLS.risk, text: it.riskLabel, risk: it.risk },
|
|
208
|
+
];
|
|
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) {
|
|
214
|
+
children.push({
|
|
215
|
+
tag: "div", cls: ACTION_CLS.confirm, children: [
|
|
216
|
+
{ tag: "div", cls: ACTION_CLS.confirmText, text: it.confirmText },
|
|
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 }] : []),
|
|
219
|
+
{ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.okLabel, action: "execute", offerId: it.offerId },
|
|
220
|
+
{ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.cancelLabel, action: "cancel", offerId: it.offerId },
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
} else if (it.canExecute) {
|
|
224
|
+
children.push({ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.buttonLabel, action: "confirm", offerId: it.offerId });
|
|
225
|
+
}
|
|
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 });
|
|
229
|
+
}
|
|
230
|
+
return { tag: "div", cls: ACTION_CLS.item, children };
|
|
231
|
+
});
|
|
232
|
+
if (v.disabledHint) items.push({ tag: "div", cls: ACTION_CLS.status, text: v.disabledHint });
|
|
233
|
+
return { tag: "div", cls: ACTION_CLS.root, children: items };
|
|
234
|
+
}
|
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;
|