@fcg-labs/cx-agent-hook 0.5.1 → 0.7.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/MIGRATION.md +47 -0
- package/actions.js +76 -0
- package/client.js +7 -4
- package/element.js +17 -2
- package/locales.js +88 -0
- package/package.json +1 -1
- package/react.d.ts +9 -0
- package/react.js +169 -15
- package/session.js +90 -7
- package/styles.css +96 -0
- package/view.d.ts +15 -0
- package/view.js +193 -11
- package/vue.js +9 -2
package/MIGRATION.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
# 0.6.0 → 0.7.0 마이그레이션
|
|
2
|
+
|
|
3
|
+
## 요약 — 좋아요/싫어요 바가 추가된다. **깨지는 것 없음** (additive)
|
|
4
|
+
|
|
5
|
+
## 새로 생긴 것
|
|
6
|
+
|
|
7
|
+
- `AiFeedbackBar`(react) + `feedbackBarView`/`feedbackBarTree`/`FB_CLS`(view 코어) —
|
|
8
|
+
상담사의 명시적 품질 판단. 신호는 **기존 `scored` 5|1** — 새 action·프로토콜
|
|
9
|
+
변경 없음(허브·서버 재배포 불필요). 비소모라 발송 전 판단 변경 가능, 집계는
|
|
10
|
+
같은 응답 최신본 우선.
|
|
11
|
+
- 배치는 답변 편집기 곁 1줄:
|
|
12
|
+
`<AiFeedbackBar session={getCsSession(csId)} agent={agent} actorClaimed={answerWriter} />`
|
|
13
|
+
- 로케일 키 4종: `ui_feedback_ask/up/down/done` (ko·en·ja·zh-TW).
|
|
14
|
+
|
|
15
|
+
## 행동 계약
|
|
16
|
+
|
|
17
|
+
- 초안(answerId)이 없으면 바 자체가 그려지지 않는다.
|
|
18
|
+
- 세션 sink 를 새로 만들지 않는다 — 초안 유무는 부모 리렌더 시점에
|
|
19
|
+
`session.adoptedAnswerId` 로 읽는다 (compose 종료·문의 전환이 리렌더를 보장).
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# 0.5.x → 0.6.0 마이그레이션
|
|
24
|
+
|
|
25
|
+
## 요약 — 대상 확정(더블체크, 계약 §B.10)이 추가된다. **깨지는 것 없음** (additive)
|
|
26
|
+
|
|
27
|
+
문의 컨텍스트 값(예: subUserId)이 PK 라는 보장이 없다 — 오타깃 실행은 다른 유저
|
|
28
|
+
데이터 손상이다. 0.6.0 부터 절차 자산이 `verify`(대상 확정)를 들고 오면 SDK 가
|
|
29
|
+
쓰기 전에 **읽기 전용 API 로 대상을 확인**한다. `verify` 없는 offer 는 0.5.x 와
|
|
30
|
+
바이트 동일하게 동작한다. 고객사 코드 변경 0 — `adminApi` 그대로.
|
|
31
|
+
|
|
32
|
+
## 새로 생긴 것
|
|
33
|
+
|
|
34
|
+
- `offer.verify` — `{action_key(읽기), execution(GET 사양), display_fields[{path,label}],
|
|
35
|
+
preconditions[], resolution}`. 서버(asset v6)가 동봉한다.
|
|
36
|
+
- 실행 흐름: 선점 → **verify 읽기(GET 강제·재시도 0)** → precondition 결정적 평가 →
|
|
37
|
+
쓰기(읽기 직후 즉시) → 보고. 차단은 전부 `failed` 로 허브 원장에 남는다 —
|
|
38
|
+
reasonCode `precondition_failed`(deny_copy 가 메시지) · `verify_unavailable` ·
|
|
39
|
+
`verify_not_readonly` · `verify_manual`.
|
|
40
|
+
- 확인 패널: 열릴 때 같은 읽기로 **대상 확인 블록**(`fcx-act-verify*`)을 렌더하고,
|
|
41
|
+
확인이 ok 되기 전에는 실행 버튼이 나오지 않는다 (위험도 무관).
|
|
42
|
+
- `session.verifyAction(offer)`(패널 미리보기) · `session.actionVerifies()`,
|
|
43
|
+
순수 함수 `pluck`·`evaluatePreconditions`·`verifyViaAdminApi` (actions.js).
|
|
44
|
+
- 로케일 키 4종: `ui_action_verify_title/loading/blocked/failed` (ko·en·ja·zh-TW).
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
1
48
|
# 0.4.0 → 0.5.0 마이그레이션
|
|
2
49
|
|
|
3
50
|
## 요약 — 처리 액션 표면만 바뀐다 (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/client.js
CHANGED
|
@@ -387,7 +387,7 @@ export class CxAgentClient {
|
|
|
387
387
|
* @param {string} [fb.note]
|
|
388
388
|
* @returns {Promise<{ok:boolean, feedbackId:number|null, error:string}>}
|
|
389
389
|
*/
|
|
390
|
-
async sendFeedback({ answerId, action, score, finalText, agent, note } = {}) {
|
|
390
|
+
async sendFeedback({ answerId, action, score, finalText, agent, note, reasonCode } = {}) {
|
|
391
391
|
// 계약 위반은 재시도해도 소용없다 — 즉시 보고
|
|
392
392
|
if (answerId === undefined || answerId === null || answerId === "") {
|
|
393
393
|
return this._fbFail(new Error("answerId 필수"), "invalid_answer_id");
|
|
@@ -411,6 +411,9 @@ export class CxAgentClient {
|
|
|
411
411
|
...(finalText ? { final_text: finalText } : {}),
|
|
412
412
|
...(agent ? { agent } : {}),
|
|
413
413
|
...(note ? { note } : {}),
|
|
414
|
+
// 사유 코드는 값 판정을 하지 않고 그대로 운반한다 — enum 정본은 서버(0057).
|
|
415
|
+
// 모르는 값이면 서버가 거절하고, 그 사실이 드러나는 게 조용한 소실보다 낫다.
|
|
416
|
+
...(reasonCode ? { reason_code: reasonCode } : {}),
|
|
414
417
|
};
|
|
415
418
|
const path = target.path(this.domain);
|
|
416
419
|
let lastErr = null;
|
|
@@ -457,9 +460,9 @@ export class CxAgentClient {
|
|
|
457
460
|
return this.sendFeedback({ answerId, action: "edited", finalText, agent });
|
|
458
461
|
}
|
|
459
462
|
|
|
460
|
-
/** 폐기 후킹 — 품질 경보
|
|
461
|
-
discarded(answerId, agent, note) {
|
|
462
|
-
return this.sendFeedback({ answerId, action: "discarded", agent, note });
|
|
463
|
+
/** 폐기 후킹 — 품질 경보 신호. reasonCode 는 서버 enum(0057) 그대로 운반한다. */
|
|
464
|
+
discarded(answerId, agent, note, reasonCode) {
|
|
465
|
+
return this.sendFeedback({ answerId, action: "discarded", agent, note, reasonCode });
|
|
463
466
|
}
|
|
464
467
|
|
|
465
468
|
/**
|
package/element.js
CHANGED
|
@@ -57,6 +57,7 @@ function createClass() {
|
|
|
57
57
|
#context = null;
|
|
58
58
|
#state = "idle";
|
|
59
59
|
#result = null;
|
|
60
|
+
#evidenceOpen = false; // 근거는 접힘이 기본 (0.7.0)
|
|
60
61
|
|
|
61
62
|
/** createCxHook() 결과 */
|
|
62
63
|
get hook() { return this.#hook; }
|
|
@@ -79,6 +80,7 @@ function createClass() {
|
|
|
79
80
|
// 후킹된다.
|
|
80
81
|
this.#state = "idle";
|
|
81
82
|
this.#result = null;
|
|
83
|
+
this.#evidenceOpen = false;
|
|
82
84
|
if (this.#hook) this.#hook.clearAdopted();
|
|
83
85
|
this.#render();
|
|
84
86
|
}
|
|
@@ -114,10 +116,14 @@ function createClass() {
|
|
|
114
116
|
result: this.#result,
|
|
115
117
|
declineText: this.#hook.declineText,
|
|
116
118
|
messages: this.#hook.messages,
|
|
119
|
+
expanded: this.#evidenceOpen,
|
|
120
|
+
ownRequestButton: this.hasAttribute("own-request-button"),
|
|
117
121
|
});
|
|
122
|
+
if (!tree) return;
|
|
118
123
|
const actions = {
|
|
119
124
|
request: () => this.#request(),
|
|
120
125
|
adopt: () => this.#adopt(),
|
|
126
|
+
evidence: () => { this.#evidenceOpen = !this.#evidenceOpen; this.#render(); },
|
|
121
127
|
};
|
|
122
128
|
const root = document.createElement("div");
|
|
123
129
|
root.className = CLS.root;
|
|
@@ -204,10 +210,19 @@ function createActionsClass() {
|
|
|
204
210
|
this.replaceChildren();
|
|
205
211
|
if (!this.#agent || !this.#session) return;
|
|
206
212
|
const tree = actionOffersTree({ offers: this.#offers, pending: this.#pending,
|
|
207
|
-
enabled: Boolean(this.#agent.actionsEnabled), confirming: this.#confirming,
|
|
213
|
+
enabled: Boolean(this.#agent.actionsEnabled), confirming: this.#confirming,
|
|
214
|
+
verifies: typeof this.#session.actionVerifies === "function" ? this.#session.actionVerifies() : {},
|
|
215
|
+
messages: this.#agent.messages });
|
|
208
216
|
if (!tree) return;
|
|
209
217
|
const actions = {
|
|
210
|
-
confirm: (e) => {
|
|
218
|
+
confirm: (e) => {
|
|
219
|
+
const id = e.currentTarget.dataset.offer || null;
|
|
220
|
+
this.#confirming = id; this.#ackedOffer = null;
|
|
221
|
+
// 대상 확정 (T2.7) — 패널이 열리면 읽기 확인을 시동한다 (결과는 notify 로 재렌더)
|
|
222
|
+
const offer = this.#offers.find((o) => o.offer_id === id);
|
|
223
|
+
if (offer && offer.verify && typeof this.#session.verifyAction === "function") this.#session.verifyAction(offer);
|
|
224
|
+
this.#render();
|
|
225
|
+
},
|
|
211
226
|
cancel: () => { this.#confirming = null; this.#ackedOffer = null; this.#render(); },
|
|
212
227
|
ack: (e) => { const id = e.currentTarget.dataset.offer; this.#ackedOffer = this.#ackedOffer === id ? null : id; this.#render(); },
|
|
213
228
|
execute: (e) => this.#execute(e.currentTarget.dataset.offer),
|
package/locales.js
CHANGED
|
@@ -29,8 +29,26 @@ const en = {
|
|
|
29
29
|
ui_request: "Suggest a reply",
|
|
30
30
|
ui_requesting: "Generating…",
|
|
31
31
|
ui_adopt: "Insert into editor",
|
|
32
|
+
ui_feedback_ask: "Was this draft helpful?",
|
|
33
|
+
ui_feedback_up: "Helpful",
|
|
34
|
+
ui_feedback_down: "Not helpful",
|
|
35
|
+
ui_feedback_done: "Recorded — it will tune future suggestions.",
|
|
32
36
|
ui_evidence: "Sources",
|
|
33
37
|
ui_correcting: "Fixing phrasing — rewriting…",
|
|
38
|
+
ui_retrieving: "Finding sources…",
|
|
39
|
+
ui_ai_label: "AI draft",
|
|
40
|
+
ui_ai_disclaimer: "AI-generated — review before sending.",
|
|
41
|
+
ui_feedback_why: "What was wrong?",
|
|
42
|
+
ui_feedback_skip: "Skip",
|
|
43
|
+
ui_feedback_note_hint: "What was wrong? (one line)",
|
|
44
|
+
ui_feedback_note_send: "Send",
|
|
45
|
+
reason_stale_period: "Outdated",
|
|
46
|
+
reason_wrong_manual: "Wrong source",
|
|
47
|
+
reason_factual_error: "Incorrect",
|
|
48
|
+
reason_missing_answer: "Incomplete",
|
|
49
|
+
reason_tone_style: "Tone",
|
|
50
|
+
reason_policy_risk: "Risky wording",
|
|
51
|
+
reason_other: "Other",
|
|
34
52
|
ui_action_execute: "Process",
|
|
35
53
|
ui_action_confirm_default: "Run this action?",
|
|
36
54
|
ui_action_confirm_ok: "Yes, run",
|
|
@@ -48,6 +66,10 @@ const en = {
|
|
|
48
66
|
ui_action_risk_low: "low risk",
|
|
49
67
|
ui_action_risk_medium: "medium risk",
|
|
50
68
|
ui_action_risk_high: "high risk",
|
|
69
|
+
ui_action_verify_title: "Confirm the target before running",
|
|
70
|
+
ui_action_verify_loading: "Checking the target…",
|
|
71
|
+
ui_action_verify_blocked: "Blocked by a pre-run check.",
|
|
72
|
+
ui_action_verify_failed: "Could not confirm the target — this action will not run.",
|
|
51
73
|
ui_overwrite_confirm: "Replace your current draft with the AI draft?",
|
|
52
74
|
// 거절·오류 사유
|
|
53
75
|
not_configured: "AI reply suggestions are not connected yet.",
|
|
@@ -72,8 +94,26 @@ const ko = {
|
|
|
72
94
|
ui_request: "AI 답변 제안",
|
|
73
95
|
ui_requesting: "제안 생성 중...",
|
|
74
96
|
ui_adopt: "에디터에 넣기",
|
|
97
|
+
ui_feedback_ask: "이 초안, 쓸 만했나요?",
|
|
98
|
+
ui_feedback_up: "좋아요",
|
|
99
|
+
ui_feedback_down: "싫어요",
|
|
100
|
+
ui_feedback_done: "기록됐습니다 — 다음 제안 품질에 반영됩니다.",
|
|
75
101
|
ui_evidence: "근거",
|
|
76
102
|
ui_correcting: "표현 교정 중 — 다시 쓰는 중...",
|
|
103
|
+
ui_retrieving: "근거 찾는 중…",
|
|
104
|
+
ui_ai_label: "AI 초안",
|
|
105
|
+
ui_ai_disclaimer: "AI가 쓴 초안입니다 — 확인 후 전송하세요.",
|
|
106
|
+
ui_feedback_why: "무엇이 문제였나요?",
|
|
107
|
+
ui_feedback_skip: "건너뛰기",
|
|
108
|
+
ui_feedback_note_hint: "무엇이 문제였나요? (한 줄)",
|
|
109
|
+
ui_feedback_note_send: "보내기",
|
|
110
|
+
reason_stale_period: "낡은 내용",
|
|
111
|
+
reason_wrong_manual: "다른 근거",
|
|
112
|
+
reason_factual_error: "내용 오류",
|
|
113
|
+
reason_missing_answer: "일부 미답",
|
|
114
|
+
reason_tone_style: "어조·표현",
|
|
115
|
+
reason_policy_risk: "위험 표현",
|
|
116
|
+
reason_other: "기타",
|
|
77
117
|
ui_action_execute: "처리하기",
|
|
78
118
|
ui_action_confirm_default: "이 처리를 진행할까요?",
|
|
79
119
|
ui_action_confirm_ok: "네, 진행",
|
|
@@ -91,6 +131,10 @@ const ko = {
|
|
|
91
131
|
ui_action_risk_low: "위험 낮음",
|
|
92
132
|
ui_action_risk_medium: "위험 보통",
|
|
93
133
|
ui_action_risk_high: "위험 높음",
|
|
134
|
+
ui_action_verify_title: "실행 전 대상 확인",
|
|
135
|
+
ui_action_verify_loading: "대상 확인 중…",
|
|
136
|
+
ui_action_verify_blocked: "실행 전 확인에서 차단되었습니다.",
|
|
137
|
+
ui_action_verify_failed: "대상을 확인할 수 없습니다 — 실행하지 않습니다.",
|
|
94
138
|
ui_overwrite_confirm: "작성 중인 답변을 지우고 AI 초안으로 바꿀까요?",
|
|
95
139
|
not_configured: "AI 답변 제안이 아직 연결되지 않았습니다.",
|
|
96
140
|
unsupported_api: "AI 답변 제안이 아직 연결되지 않았습니다.",
|
|
@@ -110,8 +154,26 @@ const ja = {
|
|
|
110
154
|
ui_request: "AI 返信案",
|
|
111
155
|
ui_requesting: "生成中...",
|
|
112
156
|
ui_adopt: "エディタに挿入",
|
|
157
|
+
ui_feedback_ask: "この下書きは役に立ちましたか?",
|
|
158
|
+
ui_feedback_up: "良い",
|
|
159
|
+
ui_feedback_down: "悪い",
|
|
160
|
+
ui_feedback_done: "記録しました — 今後の提案に反映されます。",
|
|
113
161
|
ui_evidence: "根拠",
|
|
114
162
|
ui_correcting: "表現を修正中 — 書き直しています…",
|
|
163
|
+
ui_retrieving: "根拠を検索中…",
|
|
164
|
+
ui_ai_label: "AI 下書き",
|
|
165
|
+
ui_ai_disclaimer: "AI が作成した下書きです — 確認してから送信してください。",
|
|
166
|
+
ui_feedback_why: "何が問題でしたか?",
|
|
167
|
+
ui_feedback_skip: "スキップ",
|
|
168
|
+
ui_feedback_note_hint: "何が問題でしたか?(一行)",
|
|
169
|
+
ui_feedback_note_send: "送信",
|
|
170
|
+
reason_stale_period: "古い内容",
|
|
171
|
+
reason_wrong_manual: "根拠が違う",
|
|
172
|
+
reason_factual_error: "内容の誤り",
|
|
173
|
+
reason_missing_answer: "一部未回答",
|
|
174
|
+
reason_tone_style: "語調・表現",
|
|
175
|
+
reason_policy_risk: "リスク表現",
|
|
176
|
+
reason_other: "その他",
|
|
115
177
|
ui_action_execute: "処理する",
|
|
116
178
|
ui_action_confirm_default: "この処理を実行しますか?",
|
|
117
179
|
ui_action_confirm_ok: "はい、実行",
|
|
@@ -129,6 +191,10 @@ const ja = {
|
|
|
129
191
|
ui_action_risk_low: "リスク低",
|
|
130
192
|
ui_action_risk_medium: "リスク中",
|
|
131
193
|
ui_action_risk_high: "リスク高",
|
|
194
|
+
ui_action_verify_title: "実行前に対象を確認",
|
|
195
|
+
ui_action_verify_loading: "対象を確認しています…",
|
|
196
|
+
ui_action_verify_blocked: "実行前チェックでブロックされました。",
|
|
197
|
+
ui_action_verify_failed: "対象を確認できません — 実行しません。",
|
|
132
198
|
ui_overwrite_confirm: "作成中の回答を消してAI下書きに置き換えますか?",
|
|
133
199
|
not_configured: "AI 返信案はまだ接続されていません。",
|
|
134
200
|
unsupported_api: "AI 返信案はまだ接続されていません。",
|
|
@@ -148,8 +214,26 @@ const zhTW = {
|
|
|
148
214
|
ui_request: "AI 回覆建議",
|
|
149
215
|
ui_requesting: "產生中...",
|
|
150
216
|
ui_adopt: "插入編輯器",
|
|
217
|
+
ui_feedback_ask: "這份草稿有幫助嗎?",
|
|
218
|
+
ui_feedback_up: "有幫助",
|
|
219
|
+
ui_feedback_down: "沒幫助",
|
|
220
|
+
ui_feedback_done: "已記錄 — 將反映於後續建議。",
|
|
151
221
|
ui_evidence: "依據",
|
|
152
222
|
ui_correcting: "正在修正表述 — 重新撰寫中…",
|
|
223
|
+
ui_retrieving: "尋找依據中…",
|
|
224
|
+
ui_ai_label: "AI 草稿",
|
|
225
|
+
ui_ai_disclaimer: "AI 產生的草稿 — 請確認後再傳送。",
|
|
226
|
+
ui_feedback_why: "哪裡有問題?",
|
|
227
|
+
ui_feedback_skip: "略過",
|
|
228
|
+
ui_feedback_note_hint: "哪裡有問題?(一行)",
|
|
229
|
+
ui_feedback_note_send: "送出",
|
|
230
|
+
reason_stale_period: "內容過時",
|
|
231
|
+
reason_wrong_manual: "依據不符",
|
|
232
|
+
reason_factual_error: "內容錯誤",
|
|
233
|
+
reason_missing_answer: "部分未答",
|
|
234
|
+
reason_tone_style: "語氣表達",
|
|
235
|
+
reason_policy_risk: "風險用語",
|
|
236
|
+
reason_other: "其他",
|
|
153
237
|
ui_action_execute: "處理",
|
|
154
238
|
ui_action_confirm_default: "要執行這項處理嗎?",
|
|
155
239
|
ui_action_confirm_ok: "是,執行",
|
|
@@ -167,6 +251,10 @@ const zhTW = {
|
|
|
167
251
|
ui_action_risk_low: "風險低",
|
|
168
252
|
ui_action_risk_medium: "風險中",
|
|
169
253
|
ui_action_risk_high: "風險高",
|
|
254
|
+
ui_action_verify_title: "執行前確認對象",
|
|
255
|
+
ui_action_verify_loading: "正在確認對象…",
|
|
256
|
+
ui_action_verify_blocked: "已被執行前檢查擋下。",
|
|
257
|
+
ui_action_verify_failed: "無法確認對象 — 不會執行。",
|
|
170
258
|
ui_overwrite_confirm: "要清除目前草稿並以 AI 草稿取代嗎?",
|
|
171
259
|
not_configured: "AI 回覆建議尚未連接。",
|
|
172
260
|
unsupported_api: "AI 回覆建議尚未連接。",
|
package/package.json
CHANGED
package/react.d.ts
CHANGED
|
@@ -31,4 +31,13 @@ export interface ActionOffersPanelProps {
|
|
|
31
31
|
/** 처리 선택지 카드 (actions/1 프로파일 B) — 잠김·비활성·2단 확인·상태·미보고 규약을 갖는다. */
|
|
32
32
|
export declare function ActionOffersPanel(props: ActionOffersPanelProps): ReactElement | null;
|
|
33
33
|
|
|
34
|
+
export interface AiFeedbackBarProps {
|
|
35
|
+
session: InquirySession;
|
|
36
|
+
agent: CxAgent;
|
|
37
|
+
actorClaimed?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
/** 좋아요/싫어요 바 — scored 5|1 로 원장에 남는 상담사 품질 판단 (0.7.0). */
|
|
41
|
+
export declare function AiFeedbackBar(props: AiFeedbackBarProps): ReactElement | null;
|
|
42
|
+
|
|
34
43
|
export default AiSuggestPanel;
|
package/react.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { createElement as h, useCallback, useEffect, useRef, useState } from "react";
|
|
21
21
|
|
|
22
|
-
import { actionOffersTree, aiSuggestTree, aiSuggestView, CLS } from "./view.js";
|
|
22
|
+
import { actionOffersTree, aiSuggestTree, aiSuggestView, CLS, feedbackBarTree } from "./view.js";
|
|
23
23
|
|
|
24
24
|
// 표시 판단은 프레임워크 중립 코어가 갖는다 — Vue·웹 컴포넌트와 같은 것을 쓴다.
|
|
25
25
|
export { aiSuggestView };
|
|
@@ -30,6 +30,7 @@ function renderNode(node, actions, key) {
|
|
|
30
30
|
if (node.tag === "button") {
|
|
31
31
|
props.type = node.type || "button";
|
|
32
32
|
props.disabled = Boolean(node.disabled);
|
|
33
|
+
if (node.pressed !== undefined) props["aria-pressed"] = Boolean(node.pressed);
|
|
33
34
|
}
|
|
34
35
|
if (node.action && actions[node.action]) props.onClick = actions[node.action];
|
|
35
36
|
const children = node.children
|
|
@@ -48,17 +49,62 @@ function renderNode(node, actions, key) {
|
|
|
48
49
|
* @param {(text: string) => void} props.onAdopt "에디터에 넣기"
|
|
49
50
|
* @param {string} [props.className] 바깥 배치용 (레이아웃만)
|
|
50
51
|
*/
|
|
51
|
-
|
|
52
|
+
/**
|
|
53
|
+
* 세션+agent → 패널 어댑터. **(session, agent) 쌍당 하나**만 만든다.
|
|
54
|
+
*
|
|
55
|
+
* 렌더마다 새 객체를 만들면(0.7.0 이 그랬다) 그것을 deps 로 둔 리셋 effect 가
|
|
56
|
+
* 렌더마다 돌아 `clearAdopted()` 로 세션의 answer_id 를 지운다 — compose 가
|
|
57
|
+
* 확정한 id 가 부모의 첫 리렌더에서 사라져 피드백 바가 안 뜨고, 발송 gold 쌍
|
|
58
|
+
* 후킹도 id 없이 나간다(2026-08-27 운영 실측: answered=true·answer_id 도착,
|
|
59
|
+
* `.fcx-fb` 0개). 정체성은 훅이 아니라 캐시로 고정한다 — React 밖에서도
|
|
60
|
+
* 시험할 수 있어야 한다(이 패키지는 react-dom 을 의존하지 않는다).
|
|
61
|
+
*/
|
|
62
|
+
const API_CACHE = new WeakMap(); // session → { agent, api }
|
|
63
|
+
export function sessionApi(session, agent) {
|
|
64
|
+
if (!session || !agent) return null;
|
|
65
|
+
const hit = API_CACHE.get(session);
|
|
66
|
+
if (hit && hit.agent === agent) return hit.api;
|
|
67
|
+
const api = {
|
|
68
|
+
enabled: agent.enabled,
|
|
69
|
+
messages: agent.messages,
|
|
70
|
+
declineText: agent.declineText,
|
|
71
|
+
requestAnswer: (q, ctx) => session.compose({ inquiry: q, context: ctx }).promise,
|
|
72
|
+
noteAdopted: (id) => session.noteAdopted(id),
|
|
73
|
+
clearAdopted: () => session.clearAdopted(),
|
|
74
|
+
};
|
|
75
|
+
API_CACHE.set(session, { agent, api });
|
|
76
|
+
return api;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function AiSuggestPanel({ hook, session, agent, inquiry, context, onAdopt, className, ownRequestButton }) {
|
|
80
|
+
/*
|
|
81
|
+
* 두 계약을 다 받는다 (0.7.0):
|
|
82
|
+
* - `hook` 0.2.x 단일 훅(createCxHook) — 옛 소비처 보존
|
|
83
|
+
* - `session`+`agent` 세션 기반(현행) — 초안 정본이 세션 버퍼라 문의 전환·
|
|
84
|
+
* 복귀에도 살아남는다. 세션엔 enabled·messages 가 없어 agent 가 짝이다.
|
|
85
|
+
* 섞어 넘기면(session 을 hook 자리에) 카드가 조용히 안 그려진다 —
|
|
86
|
+
* 실제로 2026-08-27 그 사고가 났다. 그래서 어댑터가 **명시적으로** 짝을
|
|
87
|
+
* 맞추고, 짝이 안 맞으면 개발 콘솔에 사유를 남긴다.
|
|
88
|
+
*/
|
|
89
|
+
const api = hook || sessionApi(session, agent);
|
|
90
|
+
if (!api && typeof console !== "undefined") {
|
|
91
|
+
console.warn("[cx-agent-hook] AiSuggestPanel: hook 또는 (session+agent) 가 필요합니다");
|
|
92
|
+
}
|
|
52
93
|
const [state, setState] = useState("idle");
|
|
53
94
|
const [result, setResult] = useState(null);
|
|
95
|
+
const [evidenceOpen, setEvidenceOpen] = useState(false); // 근거는 접힘이 기본 (0.7.0)
|
|
54
96
|
|
|
55
|
-
// 다른 문의로 옮기면 앞 문의의 제안이 남아 있으면 안 된다.
|
|
56
|
-
//
|
|
97
|
+
// 다른 문의로 옮기면 앞 문의의 제안이 남아 있으면 안 된다.
|
|
98
|
+
// 채택 기록은 **단일 훅(hook) 계약에서만** 같이 지운다 — 훅 하나가 모든 문의를
|
|
99
|
+
// 겸하므로 안 지우면 다음 문의 발송이 엉뚱한 answer_id 로 후킹된다.
|
|
100
|
+
// 세션 계약에서는 지우지 않는다: 세션이 문의별로 따로라 섞일 일이 없고,
|
|
101
|
+
// 여기서 지우면 옮겨 간 문의의 세션이 가진 id 까지 죽는다(0.7.1).
|
|
57
102
|
useEffect(() => {
|
|
58
103
|
setState("idle");
|
|
59
104
|
setResult(null);
|
|
60
|
-
|
|
61
|
-
|
|
105
|
+
setEvidenceOpen(false);
|
|
106
|
+
if (hook) hook.clearAdopted();
|
|
107
|
+
}, [inquiry, hook, api]);
|
|
62
108
|
|
|
63
109
|
// context 는 ref 로 최신값만 읽는다 — deps 에 넣으면 인라인 객체가
|
|
64
110
|
// 리렌더마다 새 참조가 되어 요청 콜백이 계속 재생성된다.
|
|
@@ -68,21 +114,24 @@ export function AiSuggestPanel({ hook, inquiry, context, onAdopt, className }) {
|
|
|
68
114
|
const request = useCallback(async () => {
|
|
69
115
|
if (!inquiry || state === "loading") return;
|
|
70
116
|
setState("loading");
|
|
71
|
-
setResult(await
|
|
117
|
+
setResult(await api.requestAnswer(inquiry, contextRef.current));
|
|
72
118
|
setState("done");
|
|
73
|
-
}, [
|
|
119
|
+
}, [api, inquiry, state]);
|
|
74
120
|
|
|
75
121
|
const adopt = useCallback(() => {
|
|
76
122
|
if (!result || !result.answered) return;
|
|
77
|
-
|
|
123
|
+
api.noteAdopted(result.answerId);
|
|
78
124
|
if (onAdopt) onAdopt(result.answer);
|
|
79
|
-
}, [
|
|
125
|
+
}, [api, onAdopt, result]);
|
|
80
126
|
|
|
81
|
-
if (!
|
|
127
|
+
if (!api || !api.enabled) return null;
|
|
82
128
|
|
|
83
129
|
const tree = aiSuggestTree({
|
|
84
|
-
state, result, declineText:
|
|
130
|
+
state, result, declineText: api.declineText, messages: api.messages,
|
|
131
|
+
expanded: evidenceOpen, ownRequestButton,
|
|
85
132
|
});
|
|
133
|
+
// 그릴 것이 없으면 빈 상자를 남기지 않는다 (0.7.0)
|
|
134
|
+
if (!tree) return null;
|
|
86
135
|
|
|
87
136
|
return h(
|
|
88
137
|
"div",
|
|
@@ -92,7 +141,9 @@ export function AiSuggestPanel({ hook, inquiry, context, onAdopt, className }) {
|
|
|
92
141
|
// 행 선택이 토글되면 제안이 초기화된다.
|
|
93
142
|
onClick: (event) => event.stopPropagation(),
|
|
94
143
|
},
|
|
95
|
-
tree.children.map((node, i) => renderNode(node, {
|
|
144
|
+
tree.children.map((node, i) => renderNode(node, {
|
|
145
|
+
request, adopt, evidence: () => setEvidenceOpen((v) => !v),
|
|
146
|
+
}, i)),
|
|
96
147
|
);
|
|
97
148
|
}
|
|
98
149
|
|
|
@@ -144,11 +195,18 @@ export function ActionOffersPanel({ session, agent, actorClaimed, onResult, clas
|
|
|
144
195
|
}, [offers, ackedOffer, session, onResult]);
|
|
145
196
|
|
|
146
197
|
if (!session || !agent) return null;
|
|
147
|
-
const tree = actionOffersTree({ offers, pending, enabled: Boolean(agent.actionsEnabled), confirming,
|
|
198
|
+
const tree = actionOffersTree({ offers, pending, enabled: Boolean(agent.actionsEnabled), confirming,
|
|
199
|
+
verifies: typeof session.actionVerifies === "function" ? session.actionVerifies() : {},
|
|
200
|
+
messages: agent.messages });
|
|
148
201
|
if (!tree) return null;
|
|
149
202
|
|
|
150
203
|
const actions = {
|
|
151
|
-
confirm: (e) => {
|
|
204
|
+
confirm: (e) => {
|
|
205
|
+
const id = e.currentTarget.dataset.offer || null;
|
|
206
|
+
setConfirming(id); setAckedOffer(null);
|
|
207
|
+
const offer = offers.find((o) => o.offer_id === id);
|
|
208
|
+
if (offer && offer.verify && typeof session.verifyAction === "function") session.verifyAction(offer); // T2.7 — 결과는 notify 재렌더
|
|
209
|
+
},
|
|
152
210
|
cancel: () => { setConfirming(null); setAckedOffer(null); },
|
|
153
211
|
ack: (e) => { const id = e.currentTarget.dataset.offer; setAckedOffer((v) => (v === id ? null : id)); },
|
|
154
212
|
execute: (e) => execute(e.currentTarget.dataset.offer),
|
|
@@ -176,3 +234,99 @@ export function ActionOffersPanel({ session, agent, actorClaimed, onResult, clas
|
|
|
176
234
|
onClick: (event) => event.stopPropagation() },
|
|
177
235
|
tree.children.map((n, i) => render(n, i)));
|
|
178
236
|
}
|
|
237
|
+
|
|
238
|
+
// ── 좋아요/싫어요 바 (0.7.0) ──────────────────────────────────────────────
|
|
239
|
+
//
|
|
240
|
+
// 답변 패널과 같은 이유로 라이브러리에 있다: 이 판단(scored 5|1)이 절차별
|
|
241
|
+
// 서빙 웨이트·재학습 라벨의 원료다. 고객사는 답변 편집기 곁에 배치만 한다.
|
|
242
|
+
//
|
|
243
|
+
// ```jsx
|
|
244
|
+
// import { AiFeedbackBar } from "@fcg-labs/cx-agent-hook/react";
|
|
245
|
+
// <AiFeedbackBar session={getCsSession(csId)} agent={agent} actorClaimed={answerWriter} />
|
|
246
|
+
// ```
|
|
247
|
+
//
|
|
248
|
+
// 세션 sink 를 새로 늘리지 않는다 — 초안 유무는 부모 리렌더 시점에
|
|
249
|
+
// `session.adoptedAnswerId` 로 읽는다 (compose 종료·문의 전환 때 CMS 가
|
|
250
|
+
// 어차피 리렌더한다). scored 는 비소모라 발송 전 판단 변경이 가능하다.
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @param {object} props
|
|
254
|
+
* @param {object} props.session agent.session(externalId)
|
|
255
|
+
* @param {object} props.agent createCxAgent() 결과 — enabled·messages
|
|
256
|
+
* @param {string} [props.actorClaimed] 행위자 이름(자기 주장) — 원장에 남는다
|
|
257
|
+
* @param {string} [props.className] 바깥 배치용 (레이아웃만)
|
|
258
|
+
*/
|
|
259
|
+
export function AiFeedbackBar({ session, agent, actorClaimed, className }) {
|
|
260
|
+
const answerId = session ? session.adoptedAnswerId : null;
|
|
261
|
+
const [voted, setVoted] = useState(null);
|
|
262
|
+
const [asking, setAsking] = useState(false); // 싫어요 뒤 사유 묻기 (선택)
|
|
263
|
+
const [noting, setNoting] = useState(false); // 「기타」 — 한 줄 자유 입력
|
|
264
|
+
const noteRef = useRef("");
|
|
265
|
+
// 다른 초안(또는 다른 문의)이 오면 앞 판단 표시가 남으면 안 된다.
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
setVoted(null); setAsking(false); setNoting(false); noteRef.current = "";
|
|
268
|
+
}, [session, answerId]);
|
|
269
|
+
|
|
270
|
+
const actorRef = useRef(actorClaimed);
|
|
271
|
+
actorRef.current = actorClaimed;
|
|
272
|
+
|
|
273
|
+
const vote = useCallback((dir) => {
|
|
274
|
+
if (!session) return;
|
|
275
|
+
// 판단은 **즉시** 기록된다 — 사유는 그 뒤의 선택이지 전제가 아니다.
|
|
276
|
+
session.scored(dir === "up" ? 5 : 1, actorRef.current);
|
|
277
|
+
setVoted(dir);
|
|
278
|
+
setAsking(dir === "down");
|
|
279
|
+
}, [session]);
|
|
280
|
+
|
|
281
|
+
const sendNote = useCallback(() => {
|
|
282
|
+
// 코드는 other, 문장은 note — 사유 코드만으로는 뜻을 못 읽는다.
|
|
283
|
+
if (session) session.discarded(actorRef.current, noteRef.current.trim(), "other");
|
|
284
|
+
setNoting(false); setAsking(false); noteRef.current = "";
|
|
285
|
+
}, [session]);
|
|
286
|
+
|
|
287
|
+
const pickReason = useCallback((code) => {
|
|
288
|
+
/*
|
|
289
|
+
* 사유는 **폐기의 이유**로 남는다 — 서버 계약(store/serving.py:600)이
|
|
290
|
+
* `reason_code` 를 discarded 에만 허용한다. 점수는 이미 남았고(1클릭),
|
|
291
|
+
* 여기서 한 번 더 남기는 것은 "이 초안을 버린 이유"다.
|
|
292
|
+
* discarded 는 소모라 이 뒤로 그 초안의 판단은 닫힌다.
|
|
293
|
+
*/
|
|
294
|
+
if (code === "other") { setNoting(true); return; } // 기타 → 한 줄 적기
|
|
295
|
+
if (session && code) session.discarded(actorRef.current, "", code);
|
|
296
|
+
setAsking(false);
|
|
297
|
+
}, [session]);
|
|
298
|
+
|
|
299
|
+
if (!session || !agent || !agent.enabled) return null;
|
|
300
|
+
const tree = feedbackBarTree({ answerId, voted, asking, noting, messages: agent.messages });
|
|
301
|
+
if (!tree) return null;
|
|
302
|
+
|
|
303
|
+
const actions = {
|
|
304
|
+
up: () => vote("up"),
|
|
305
|
+
down: () => vote("down"),
|
|
306
|
+
reason: (e) => pickReason(e.currentTarget.dataset.reason || ""),
|
|
307
|
+
skip: () => { setAsking(false); setNoting(false); },
|
|
308
|
+
note: (e) => { noteRef.current = e.target.value; },
|
|
309
|
+
noteSend: () => sendNote(),
|
|
310
|
+
};
|
|
311
|
+
const render = (node, key) => {
|
|
312
|
+
const props = { key, className: node.cls };
|
|
313
|
+
if (node.tag === "button") {
|
|
314
|
+
props.type = node.type || "button";
|
|
315
|
+
if (node.action && actions[node.action]) props.onClick = actions[node.action];
|
|
316
|
+
if (node.reason) props["data-reason"] = node.reason;
|
|
317
|
+
if (node.pressed !== undefined) props["aria-pressed"] = Boolean(node.pressed);
|
|
318
|
+
} else if (node.tag === "input") {
|
|
319
|
+
props.type = "text";
|
|
320
|
+
props.placeholder = node.placeholder || "";
|
|
321
|
+
props.defaultValue = "";
|
|
322
|
+
if (node.action && actions[node.action]) props.onChange = actions[node.action];
|
|
323
|
+
// Enter 로도 보낼 수 있게 — 한 줄 입력에 버튼만 두면 손이 한 번 더 간다
|
|
324
|
+
props.onKeyDown = (e) => { if (e.key === "Enter") { e.preventDefault(); sendNote(); } };
|
|
325
|
+
}
|
|
326
|
+
if (node.live) props.role = "status";
|
|
327
|
+
return h(node.tag, props, node.children ? node.children.map((c, i) => render(c, i)) : node.text);
|
|
328
|
+
};
|
|
329
|
+
return h("div", { className: className ? `${tree.cls} ${className}` : tree.cls,
|
|
330
|
+
onClick: (event) => event.stopPropagation() },
|
|
331
|
+
tree.children.map((n, i) => render(n, i)));
|
|
332
|
+
}
|
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 } = {}) {
|
|
@@ -139,6 +139,14 @@ export class SessionStore {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** 의사 스트림 (0.7.0) — 통짜 청크로 판정하는 길이. 이보다 크면 조각내어 보여 준다. */
|
|
143
|
+
const BULK_CHUNK_CHARS = 120;
|
|
144
|
+
/** 조각 표시 주기(ms)와 전체를 다 보여주는 데 쓰는 틱 수 — 약 1.4초에 완주. */
|
|
145
|
+
const REVEAL_INTERVAL_MS = 40;
|
|
146
|
+
const REVEAL_TICKS = 35;
|
|
147
|
+
/** 한 틱 최소 글자 — 짧은 답변이 지나치게 느리게 흐르지 않게. */
|
|
148
|
+
const REVEAL_MIN_STEP = 2;
|
|
149
|
+
|
|
142
150
|
export class InquirySession {
|
|
143
151
|
/**
|
|
144
152
|
* 직접 만들지 않는다 — `agent.session(externalId)` 가 만든다.
|
|
@@ -156,6 +164,7 @@ export class InquirySession {
|
|
|
156
164
|
this._offers = []; // 마지막 답변의 처리 선택지 (actions/1 §4)
|
|
157
165
|
this._actionUi = null;
|
|
158
166
|
this._locks = {}; // action_key\x00target_key → 잠금 조회 결과 (세션 캐시)
|
|
167
|
+
this._verify = {}; // offer_id → 대상 확정(더블체크) 결과 (T2.7, 화면 표시용 캐시)
|
|
159
168
|
|
|
160
169
|
// 영속 복원 — TTL 만료분은 store 가 이미 걸렀다
|
|
161
170
|
if (this._id && deps.store) {
|
|
@@ -263,6 +272,27 @@ export class InquirySession {
|
|
|
263
272
|
return { requestId, ...r };
|
|
264
273
|
}
|
|
265
274
|
if (d.actionLedger) d.actionLedger.put(requestId, { ...rec, state: "received" });
|
|
275
|
+
// ②-a 대상 확정 (T2.7, 계약 §B.10) — verify 동봉 offer 는 **쓰기 직전에 읽기 재검**.
|
|
276
|
+
// 패널의 verifyAction 표시와 별개로 여기서 다시 읽는다 (TOCTOU 최소화 — 읽기
|
|
277
|
+
// 직후 즉시 쓰기). 읽기 실패·precondition 불통과 = 실행 차단 + 원장 보고.
|
|
278
|
+
if (offer.verify) {
|
|
279
|
+
const vr = await verifyViaAdminApi(d.adminApi, offer);
|
|
280
|
+
this._verify[offer.offer_id] = { state: vr.ok ? "ok" : "blocked", reasonCode: vr.reasonCode || "",
|
|
281
|
+
denyCopy: vr.denyCopy || "", message: vr.message || "", display: vr.display || [] };
|
|
282
|
+
if (!vr.ok) {
|
|
283
|
+
const reasonCode = vr.reasonCode || "verify_unavailable";
|
|
284
|
+
const message = vr.denyCopy || vr.message || "";
|
|
285
|
+
const report = { state: "failed", reasonCode, message, httpStatus: vr.httpStatus || 0, latencyMs: 0 };
|
|
286
|
+
const rep = await d.client.reportActionResult(requestId, report);
|
|
287
|
+
const unreported = !rep.ok && rep.httpStatus === 0;
|
|
288
|
+
if (d.actionLedger) {
|
|
289
|
+
d.actionLedger.put(requestId, { ...rec, state: "failed", reasonCode, message,
|
|
290
|
+
unreported, pendingReport: unreported ? report : undefined });
|
|
291
|
+
}
|
|
292
|
+
this._notifyActions();
|
|
293
|
+
return { requestId, ok: true, state: "failed", reasonCode, message, reported: !unreported, error: null };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
266
296
|
// ② CMS 자기 API — 재시도 0
|
|
267
297
|
const out = await executeViaAdminApi(d.adminApi, offer);
|
|
268
298
|
if (out.authExpired && typeof d.adminApi.onAuthExpired === "function") {
|
|
@@ -285,6 +315,25 @@ export class InquirySession {
|
|
|
285
315
|
return { requestId, ok: true, state: out.state, reasonCode: out.reasonCode, message: out.message,
|
|
286
316
|
reported: !unreported, error: null };
|
|
287
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* 대상 확정 미리보기 (T2.7) — 확인 패널이 열릴 때 UI 가 부른다. 결과는 캐시돼
|
|
320
|
+
* 카드의 "대상 확인" 블록에 표시되고, 실행 버튼은 ok 일 때만 나온다.
|
|
321
|
+
* 실제 실행(executeAction)은 이 캐시를 믿지 않고 쓰기 직전에 다시 읽는다.
|
|
322
|
+
*/
|
|
323
|
+
async verifyAction(offer) {
|
|
324
|
+
const d = this._d;
|
|
325
|
+
if (!offer || !offer.offer_id || !offer.verify) return { ok: true, skipped: true, display: [] };
|
|
326
|
+
if (!d.adminApi || typeof d.adminApi.request !== "function") return { ok: false, reasonCode: "admin_api_missing", display: [] };
|
|
327
|
+
this._verify[offer.offer_id] = { state: "loading", reasonCode: "", denyCopy: "", message: "", display: [] };
|
|
328
|
+
this._notifyActions();
|
|
329
|
+
const out = await verifyViaAdminApi(d.adminApi, offer);
|
|
330
|
+
this._verify[offer.offer_id] = { state: out.ok ? "ok" : "blocked", reasonCode: out.reasonCode || "",
|
|
331
|
+
denyCopy: out.denyCopy || "", message: out.message || "", display: out.display || [] };
|
|
332
|
+
this._notifyActions();
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
/** offer_id → 대상 확정 상태 스냅샷 — view 의 `verifies` 입력. */
|
|
336
|
+
actionVerifies() { return { ...this._verify }; }
|
|
288
337
|
/** 미보고 결과 재보고 — 재실행이 아니다. ledger 의 pendingReport 만 다시 보낸다. */
|
|
289
338
|
async resendResult(requestId) {
|
|
290
339
|
const d = this._d;
|
|
@@ -416,6 +465,27 @@ export class InquirySession {
|
|
|
416
465
|
};
|
|
417
466
|
const clearPending = () => {
|
|
418
467
|
if (pending !== null) { clearTimeout(pending); pending = null; }
|
|
468
|
+
if (reveal !== null) { clearInterval(reveal); reveal = null; }
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
/*
|
|
472
|
+
* 의사 스트림 (0.7.0) — 공급자가 통짜로 줄 때도 글자가 흐르게.
|
|
473
|
+
*
|
|
474
|
+
* codex 계열은 답변을 **한 번에** 낸다(finish_reason='single_chunk') — 그대로
|
|
475
|
+
* 그리면 15초 정적 뒤 완성본이 튀어나와 "AI 가 쓰고 있다"는 신호가 0 이다.
|
|
476
|
+
* 진짜 토큰 스트림이면 이 경로는 타지 않는다(작은 청크는 그대로 흐른다).
|
|
477
|
+
* 정본(this._draft)은 늘 전체 텍스트다 — 화면에 보이는 양만 늦춘다.
|
|
478
|
+
*/
|
|
479
|
+
let reveal = null;
|
|
480
|
+
let revealed = 0;
|
|
481
|
+
const revealFrom = (full) => {
|
|
482
|
+
if (reveal !== null) { clearInterval(reveal); reveal = null; }
|
|
483
|
+
const step = Math.max(REVEAL_MIN_STEP, Math.ceil(full.length / REVEAL_TICKS));
|
|
484
|
+
reveal = setInterval(() => {
|
|
485
|
+
revealed = Math.min(full.length, revealed + step);
|
|
486
|
+
if (this._ui) this._ui.setDraft(decorate(full.slice(0, revealed)));
|
|
487
|
+
if (revealed >= full.length) { clearInterval(reveal); reveal = null; }
|
|
488
|
+
}, REVEAL_INTERVAL_MS);
|
|
419
489
|
};
|
|
420
490
|
|
|
421
491
|
this._state = "composing";
|
|
@@ -427,7 +497,17 @@ export class InquirySession {
|
|
|
427
497
|
signal: controller.signal,
|
|
428
498
|
...(idleTimeoutMs ? { idleTimeoutMs } : {}),
|
|
429
499
|
...(overallTimeoutMs ? { overallTimeoutMs } : {}),
|
|
430
|
-
onDelta: (text) => {
|
|
500
|
+
onDelta: (text) => {
|
|
501
|
+
accumulated += text;
|
|
502
|
+
// 통짜 청크(공급자가 한 번에 준 것)면 조각내어 흘린다 — 판정은 길이뿐이라
|
|
503
|
+
// 결정적이고, 진짜 스트림의 작은 청크는 기존 스로틀 경로 그대로.
|
|
504
|
+
if (text.length >= BULK_CHUNK_CHARS) {
|
|
505
|
+
this._draft = decorate(accumulated);
|
|
506
|
+
revealFrom(accumulated);
|
|
507
|
+
} else {
|
|
508
|
+
queueDraft();
|
|
509
|
+
}
|
|
510
|
+
},
|
|
431
511
|
onRestart: () => {
|
|
432
512
|
// 계약 위반 교정 — 지금까지 보인 초안은 폐기본이다
|
|
433
513
|
clearPending();
|
|
@@ -437,7 +517,10 @@ export class InquirySession {
|
|
|
437
517
|
this._setStatus(d.messages.ui_correcting);
|
|
438
518
|
},
|
|
439
519
|
onStage: (phase) => {
|
|
440
|
-
|
|
520
|
+
// 단계를 사람 말로 — "제안 생성 중" 한 문구로는 15초 동안 무엇이
|
|
521
|
+
// 진행 중인지 알 수 없다(근거 검색과 생성은 체감이 다른 구간이다).
|
|
522
|
+
if (phase === "retrieving") this._setStatus(d.messages.ui_retrieving);
|
|
523
|
+
else if (phase === "generating") this._setStatus(d.messages.ui_requesting);
|
|
441
524
|
},
|
|
442
525
|
}).then((result) => {
|
|
443
526
|
clearPending();
|
|
@@ -524,7 +607,7 @@ export class InquirySession {
|
|
|
524
607
|
this._emit((id) => this._d.client.sent(id, finalText, agent), true);
|
|
525
608
|
}
|
|
526
609
|
|
|
527
|
-
/** 품질 점수 1~5 — 발송 전 여러 번 가능 (비소모) */
|
|
610
|
+
/** 품질 점수 1~5 — 발송 전 여러 번 가능 (비소모). 사유는 여기 안 실린다(아래 계약). */
|
|
528
611
|
scored(score, agent) {
|
|
529
612
|
this._emit((id) => this._d.client.scored(id, score, agent), false);
|
|
530
613
|
}
|
|
@@ -534,8 +617,8 @@ export class InquirySession {
|
|
|
534
617
|
this._emit((id) => this._d.client.edited(id, finalText, agent), false);
|
|
535
618
|
}
|
|
536
619
|
|
|
537
|
-
/** 폐기 + 이유 — 마지막 판단이라 기록을
|
|
538
|
-
discarded(agent, note) {
|
|
539
|
-
this._emit((id) => this._d.client.discarded(id, agent, note), true);
|
|
620
|
+
/** 폐기 + 이유 — 마지막 판단이라 기록을 비운다. reasonCode 는 서버 enum(0057). */
|
|
621
|
+
discarded(agent, note, reasonCode) {
|
|
622
|
+
this._emit((id) => this._d.client.discarded(id, agent, note, reasonCode), true);
|
|
540
623
|
}
|
|
541
624
|
}
|
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;
|
|
@@ -163,8 +172,95 @@
|
|
|
163
172
|
[data-fcx-theme="dark"] .fcx-act { color: var(--fcx-text, #dbe6f3); }
|
|
164
173
|
[data-fcx-theme="dark"] .fcx-act-params, [data-fcx-theme="dark"] .fcx-act-status { color: var(--fcx-muted, #8ba0b8); }
|
|
165
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); }
|
|
166
176
|
[data-fcx-theme="dark"] .fcx-act-locked { background: var(--fcx-success-bg, #12301c); }
|
|
167
177
|
|
|
168
178
|
@media (prefers-reduced-motion: reduce) {
|
|
169
179
|
.fcx-ai-button, .fcx-ai-adopt, .fcx-act-button, .fcx-act-resend { transition: none; }
|
|
170
180
|
}
|
|
181
|
+
|
|
182
|
+
/* ── 좋아요/싫어요 바 (0.7.0) ─────────────────────────────────────────── */
|
|
183
|
+
/* ⚠ 줄바꿈 금지 — 편집기 폭이 좁으면 "좋아요" 가 세로로 쪼개진다(2026-08-27 실측).
|
|
184
|
+
flex 아이템은 기본적으로 자기 콘텐츠보다 작게 줄어든다(min-width:auto 예외 아님). */
|
|
185
|
+
.fcx-fb { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 13px; flex-wrap: wrap; }
|
|
186
|
+
.fcx-fb-label { color: var(--fcx-muted, #5b6b7d); }
|
|
187
|
+
.fcx-fb-up, .fcx-fb-down {
|
|
188
|
+
padding: 3px 10px; border-radius: 6px; font-size: 13px; cursor: pointer;
|
|
189
|
+
border: 1px solid var(--fcx-border, #d4dde7); background: var(--fcx-surface, #fff);
|
|
190
|
+
color: var(--fcx-text, #1c2733);
|
|
191
|
+
white-space: nowrap; flex: 0 0 auto;
|
|
192
|
+
}
|
|
193
|
+
.fcx-fb-up[aria-pressed="true"] { background: var(--fcx-success-bg, #e8f5ec); border-color: var(--fcx-success, #1f7a3f); color: var(--fcx-success, #1f7a3f); }
|
|
194
|
+
.fcx-fb-down[aria-pressed="true"] { background: var(--fcx-danger-bg, #fdecea); border-color: var(--fcx-danger, #b42318); color: var(--fcx-danger, #b42318); }
|
|
195
|
+
.fcx-fb-done { color: var(--fcx-muted, #5b6b7d); }
|
|
196
|
+
|
|
197
|
+
[data-fcx-theme="dark"] .fcx-fb-up, [data-fcx-theme="dark"] .fcx-fb-down {
|
|
198
|
+
background: var(--fcx-surface, #10182a); border-color: var(--fcx-border, #24344d); color: var(--fcx-text, #dbe6f3);
|
|
199
|
+
}
|
|
200
|
+
[data-fcx-theme="dark"] .fcx-fb-up[aria-pressed="true"] { background: var(--fcx-success-bg, #12301c); }
|
|
201
|
+
[data-fcx-theme="dark"] .fcx-fb-down[aria-pressed="true"] { background: var(--fcx-danger-bg, #3a1512); }
|
|
202
|
+
|
|
203
|
+
/* ── AI 제안 카드 (0.7.0) — 표식·근거 3단·고지 ─────────────────────────── */
|
|
204
|
+
.fcx-ai-label {
|
|
205
|
+
align-items: center; display: inline-flex; gap: 4px;
|
|
206
|
+
border: 1px solid var(--fcx-brand-border, #d4dde7);
|
|
207
|
+
border-radius: 999px; padding: 1px 8px;
|
|
208
|
+
font-size: 11px; font-weight: 600; letter-spacing: 0.01em;
|
|
209
|
+
color: var(--fcx-brand, #5b6b7d);
|
|
210
|
+
}
|
|
211
|
+
.fcx-ai-label::before { content: "✦"; font-size: 10px; }
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
/* 근거 — 접힌 칩이 기본, 펼치면 목록 */
|
|
215
|
+
.fcx-ai-evidence { margin-top: 6px; }
|
|
216
|
+
.fcx-ai-evidence-chip {
|
|
217
|
+
border: 1px solid var(--fcx-border, #d4dde7); background: var(--fcx-surface, #fff);
|
|
218
|
+
border-radius: 999px; padding: 2px 10px; font-size: 12px; cursor: pointer;
|
|
219
|
+
color: var(--fcx-muted, #5b6b7d);
|
|
220
|
+
}
|
|
221
|
+
.fcx-ai-evidence-chip::after { content: " ⌄"; }
|
|
222
|
+
.fcx-ai-evidence-chip[aria-pressed="true"]::after { content: " ⌃"; }
|
|
223
|
+
.fcx-ai-evidence-list { margin-top: 6px; display: grid; gap: 4px; }
|
|
224
|
+
.fcx-ai-evidence-item {
|
|
225
|
+
font-size: 12px; color: var(--fcx-muted, #5b6b7d);
|
|
226
|
+
padding-left: 10px; border-left: 2px solid var(--fcx-border, #d4dde7);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* 고지 — 상시, 그러나 조용하게 */
|
|
230
|
+
.fcx-ai-disclaimer { margin-top: 8px; font-size: 11px; color: var(--fcx-muted, #5b6b7d); }
|
|
231
|
+
|
|
232
|
+
[data-fcx-theme="dark"] .fcx-ai-evidence-chip { background: var(--fcx-surface, #10182a); border-color: var(--fcx-border, #24344d); }
|
|
233
|
+
[data-fcx-theme="dark"] .fcx-ai-evidence-item { border-left-color: var(--fcx-border, #24344d); }
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
/* 싫어요 사유 (0.7.0) — 1클릭 판단 뒤의 **선택** 단계. 강제하지 않는다. */
|
|
237
|
+
.fcx-fb-why { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; }
|
|
238
|
+
.fcx-fb-why-label { color: var(--fcx-muted, #5b6b7d); font-size: 12px; }
|
|
239
|
+
.fcx-fb-reason, .fcx-fb-skip {
|
|
240
|
+
border: 1px solid var(--fcx-border, #d4dde7); background: var(--fcx-surface, #fff);
|
|
241
|
+
border-radius: 999px; padding: 2px 9px; font-size: 12px; cursor: pointer;
|
|
242
|
+
color: var(--fcx-text, #1c2733); transition: background-color 140ms ease, border-color 140ms ease;
|
|
243
|
+
white-space: nowrap; flex: 0 0 auto;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* 기타 = 자유 입력 (2026-08-27 사용자 지적: "기타"만으로는 싫어요의 의미를
|
|
247
|
+
알 수 없다). 사유 코드는 other 로 남고, 적은 문장은 note 로 함께 간다. */
|
|
248
|
+
.fcx-fb-note { display: flex; gap: 6px; align-items: center; width: 100%; margin-top: 6px; }
|
|
249
|
+
.fcx-fb-note-input {
|
|
250
|
+
flex: 1 1 auto; min-width: 0;
|
|
251
|
+
border: 1px solid var(--fcx-border, #d4dde7); border-radius: 6px;
|
|
252
|
+
padding: 4px 8px; font-size: 12px; color: var(--fcx-text, #1c2733);
|
|
253
|
+
background: var(--fcx-surface, #fff);
|
|
254
|
+
}
|
|
255
|
+
.fcx-fb-note-send {
|
|
256
|
+
flex: 0 0 auto; white-space: nowrap;
|
|
257
|
+
border: 1px solid var(--fcx-brand-border, #d4dde7); background: var(--fcx-surface, #fff);
|
|
258
|
+
border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer;
|
|
259
|
+
color: var(--fcx-brand, #1c2733);
|
|
260
|
+
}
|
|
261
|
+
.fcx-fb-reason:hover { background: var(--fcx-danger-bg, #fdecea); border-color: var(--fcx-danger, #b42318); }
|
|
262
|
+
.fcx-fb-skip { color: var(--fcx-muted, #5b6b7d); border-style: dashed; }
|
|
263
|
+
|
|
264
|
+
[data-fcx-theme="dark"] .fcx-fb-reason, [data-fcx-theme="dark"] .fcx-fb-skip {
|
|
265
|
+
background: var(--fcx-surface, #10182a); border-color: var(--fcx-border, #24344d); color: var(--fcx-text, #dbe6f3);
|
|
266
|
+
}
|
package/view.d.ts
CHANGED
|
@@ -69,3 +69,18 @@ export declare function actionOffersView(input: {
|
|
|
69
69
|
}): { visible: boolean; items: ActionOfferItemView[]; disabledHint: string };
|
|
70
70
|
/** action: "confirm" | "execute" | "cancel" | "resend" | "ack" — 텍스트 노드만 */
|
|
71
71
|
export declare function actionOffersTree(input: Parameters<typeof actionOffersView>[0]): AiSuggestNode | null;
|
|
72
|
+
|
|
73
|
+
// ── 좋아요/싫어요 (0.7.0) ────────────────────────────────────────────────
|
|
74
|
+
export declare const FB_CLS: {
|
|
75
|
+
root: string; label: string; up: string; down: string; done: string;
|
|
76
|
+
};
|
|
77
|
+
export declare function feedbackBarView(input: {
|
|
78
|
+
answerId?: string | number | null;
|
|
79
|
+
voted?: "up" | "down" | null;
|
|
80
|
+
messages?: Record<string, string>;
|
|
81
|
+
}): {
|
|
82
|
+
label: string; upLabel: string; downLabel: string; doneText: string;
|
|
83
|
+
voted: "up" | "down" | null;
|
|
84
|
+
} | null;
|
|
85
|
+
/** action: "up" | "down" — 어댑터가 자기 이벤트로 배선한다 */
|
|
86
|
+
export declare function feedbackBarTree(input: Parameters<typeof feedbackBarView>[0]): AiSuggestNode | null;
|
package/view.js
CHANGED
|
@@ -29,10 +29,18 @@ export function aiSuggestView({ state, result, declineText, messages = {} }) {
|
|
|
29
29
|
body = {
|
|
30
30
|
kind: "answer",
|
|
31
31
|
text: result.answer || "",
|
|
32
|
-
|
|
32
|
+
/*
|
|
33
|
+
* 근거는 **구조로** 낸다 (0.7.0). 예전엔 제목을 이어붙인 문자열 하나라
|
|
34
|
+
* 소비처가 칩·목록·원문의 3단 접기를 만들 수 없었다 — 화면이 근거를
|
|
35
|
+
* 아예 안 그리는 실질적 원인이었다. 키는 남긴다(클릭 시 원문 조회용).
|
|
36
|
+
*/
|
|
33
37
|
evidence: (result.evidence || [])
|
|
34
|
-
.map((e) =>
|
|
35
|
-
|
|
38
|
+
.map((e) => ({
|
|
39
|
+
key: e.unit_key || "",
|
|
40
|
+
title: e.title || e.unit_key || "",
|
|
41
|
+
score: typeof e.score === "number" ? e.score : null,
|
|
42
|
+
}))
|
|
43
|
+
.filter((e) => e.title),
|
|
36
44
|
evidenceLabel: messages.ui_evidence,
|
|
37
45
|
};
|
|
38
46
|
} else if (done) {
|
|
@@ -44,6 +52,10 @@ export function aiSuggestView({ state, result, declineText, messages = {} }) {
|
|
|
44
52
|
buttonDisabled: loading,
|
|
45
53
|
showAdopt: answered,
|
|
46
54
|
adoptLabel: messages.ui_adopt,
|
|
55
|
+
// AI 산출물의 상시 표식 — Carbon 의 AI label 자리(장식 아님, 설명의 문).
|
|
56
|
+
aiLabel: messages.ui_ai_label,
|
|
57
|
+
// "확인 후 전송" 상시 고지 — Cloudscape 의 disclaimer 블록 자리.
|
|
58
|
+
disclaimer: answered ? messages.ui_ai_disclaimer : "",
|
|
47
59
|
body,
|
|
48
60
|
};
|
|
49
61
|
}
|
|
@@ -51,11 +63,16 @@ export function aiSuggestView({ state, result, declineText, messages = {} }) {
|
|
|
51
63
|
export const CLS = {
|
|
52
64
|
root: "fcx-ai",
|
|
53
65
|
head: "fcx-ai-head",
|
|
66
|
+
label: "fcx-ai-label",
|
|
54
67
|
button: "fcx-ai-button",
|
|
55
68
|
adopt: "fcx-ai-adopt",
|
|
56
69
|
body: "fcx-ai-body",
|
|
57
70
|
answer: "fcx-ai-answer",
|
|
58
71
|
evidence: "fcx-ai-evidence",
|
|
72
|
+
evidenceChip: "fcx-ai-evidence-chip",
|
|
73
|
+
evidenceList: "fcx-ai-evidence-list",
|
|
74
|
+
evidenceItem: "fcx-ai-evidence-item",
|
|
75
|
+
disclaimer: "fcx-ai-disclaimer",
|
|
59
76
|
declined: "fcx-ai-declined",
|
|
60
77
|
};
|
|
61
78
|
|
|
@@ -75,11 +92,19 @@ export const CLS = {
|
|
|
75
92
|
*/
|
|
76
93
|
export function aiSuggestTree(input) {
|
|
77
94
|
const v = aiSuggestView(input);
|
|
95
|
+
const expanded = Boolean(input && input.expanded);
|
|
96
|
+
// 소비처가 요청 버튼을 자기 화면에 따로 뒀는가 (그러면 카드 안 버튼은 잉여다)
|
|
97
|
+
const ownRequestButton = Boolean(input && input.ownRequestButton);
|
|
78
98
|
const head = {
|
|
79
99
|
tag: "div", cls: CLS.head,
|
|
80
100
|
children: [
|
|
81
|
-
|
|
82
|
-
|
|
101
|
+
// AI 산출물의 상시 표식 — 답변이 있을 때만(빈 카드에 라벨을 달지 않는다)
|
|
102
|
+
...(v.body.kind === "answer" && v.aiLabel
|
|
103
|
+
? [{ tag: "span", cls: CLS.label, text: v.aiLabel }] : []),
|
|
104
|
+
...(ownRequestButton ? [] : [{
|
|
105
|
+
tag: "button", cls: CLS.button, type: "button",
|
|
106
|
+
disabled: v.buttonDisabled, text: v.buttonLabel, action: "request",
|
|
107
|
+
}]),
|
|
83
108
|
...(v.showAdopt
|
|
84
109
|
? [{ tag: "button", cls: CLS.adopt, type: "button",
|
|
85
110
|
disabled: false, text: v.adoptLabel, action: "adopt" }]
|
|
@@ -88,19 +113,46 @@ export function aiSuggestTree(input) {
|
|
|
88
113
|
};
|
|
89
114
|
const children = [head];
|
|
90
115
|
if (v.body.kind === "answer") {
|
|
116
|
+
/*
|
|
117
|
+
* 근거는 3단으로 접는다 (0.7.0, 관행 조사 반영): 칩("근거 N") → 목록 →
|
|
118
|
+
* (소비처가 원하면) 원문. 기본은 접힘 — 상담사가 읽는 것은 답변이지
|
|
119
|
+
* 출처 목록이 아니다. 펼침 상태는 어댑터가 `expanded` 로 준다.
|
|
120
|
+
*/
|
|
121
|
+
const ev = v.body.evidence;
|
|
122
|
+
const evNodes = ev.length > 0
|
|
123
|
+
? [{
|
|
124
|
+
tag: "div", cls: CLS.evidence,
|
|
125
|
+
children: [
|
|
126
|
+
{ tag: "button", cls: CLS.evidenceChip, type: "button",
|
|
127
|
+
text: `${v.body.evidenceLabel} ${ev.length}`, action: "evidence",
|
|
128
|
+
pressed: Boolean(expanded) },
|
|
129
|
+
...(expanded
|
|
130
|
+
? [{ tag: "div", cls: CLS.evidenceList,
|
|
131
|
+
children: ev.map((e) => ({
|
|
132
|
+
tag: "div", cls: CLS.evidenceItem, text: e.title,
|
|
133
|
+
})) }]
|
|
134
|
+
: []),
|
|
135
|
+
],
|
|
136
|
+
}]
|
|
137
|
+
: [];
|
|
91
138
|
children.push({
|
|
92
139
|
tag: "div", cls: CLS.body,
|
|
93
140
|
children: [
|
|
94
141
|
{ tag: "div", cls: CLS.answer, text: v.body.text },
|
|
95
|
-
...
|
|
96
|
-
|
|
97
|
-
text: `${v.body.evidenceLabel}: ${v.body.evidence.join(" · ")}` }]
|
|
98
|
-
: []),
|
|
142
|
+
...evNodes,
|
|
143
|
+
...(v.disclaimer ? [{ tag: "div", cls: CLS.disclaimer, text: v.disclaimer }] : []),
|
|
99
144
|
],
|
|
100
145
|
});
|
|
101
146
|
} else if (v.body.kind === "declined") {
|
|
102
147
|
children.push({ tag: "div", cls: CLS.declined, text: v.body.text });
|
|
103
148
|
}
|
|
149
|
+
/*
|
|
150
|
+
* 요청 버튼을 소비처가 자기 화면에 따로 두면(`ownRequestButton`), 초안이 없는
|
|
151
|
+
* 동안 이 카드는 **테두리·여백만 남은 빈 상자**가 된다 — 2026-08-27 실사고:
|
|
152
|
+
* CSS 로 버튼만 숨겼더니 화면에 빈 카드가 떠 있었다. 그때는 아예 그리지 않는다.
|
|
153
|
+
* SDK 는 소비처의 CSS 를 모르므로 **소비처가 선언**한다(추론 금지).
|
|
154
|
+
*/
|
|
155
|
+
if (ownRequestButton && v.body.kind === "none") return null;
|
|
104
156
|
return { tag: "div", cls: CLS.root, children };
|
|
105
157
|
}
|
|
106
158
|
|
|
@@ -126,6 +178,10 @@ export const ACTION_CLS = {
|
|
|
126
178
|
button: "fcx-act-button",
|
|
127
179
|
confirm: "fcx-act-confirm",
|
|
128
180
|
confirmText: "fcx-act-confirm-text",
|
|
181
|
+
verify: "fcx-act-verify",
|
|
182
|
+
verifyTitle: "fcx-act-verify-title",
|
|
183
|
+
verifyLine: "fcx-act-verify-line",
|
|
184
|
+
verifyStatus: "fcx-act-verify-status",
|
|
129
185
|
ack: "fcx-act-ack",
|
|
130
186
|
status: "fcx-act-status",
|
|
131
187
|
locked: "fcx-act-locked",
|
|
@@ -148,9 +204,10 @@ function fmtWhen(iso) {
|
|
|
148
204
|
* @param {Array} pending session.pendingActions() [{requestId, offerId, state, reasonCode, message, label, unreported}]
|
|
149
205
|
* @param {boolean} enabled agent.actionsEnabled
|
|
150
206
|
* @param {string|null} confirming 확인 패널이 열린 offer_id
|
|
207
|
+
* @param {Record<string,object>} verifies session.actionVerifies() — offer_id → 대상 확정 상태 (T2.7)
|
|
151
208
|
* @param {Record<string,string>} messages
|
|
152
209
|
*/
|
|
153
|
-
export function actionOffersView({ offers = [], pending = [], enabled = true, confirming = null, messages = {} }) {
|
|
210
|
+
export function actionOffersView({ offers = [], pending = [], enabled = true, confirming = null, verifies = {}, messages = {} }) {
|
|
154
211
|
const byOffer = new Map();
|
|
155
212
|
for (const p of pending) byOffer.set(p.offerId, p);
|
|
156
213
|
const items = offers.map((o) => {
|
|
@@ -168,6 +225,16 @@ export function actionOffersView({ offers = [], pending = [], enabled = true, co
|
|
|
168
225
|
// success_copy 는 실행이 실제로 성공한 뒤에만 — 절차 자산의 "실행 성공 후 문구"
|
|
169
226
|
// (실행 전 카드·답변에 실으면 완료 사칭이 된다. 서버도 같은 이유로 승인 게이트를 둔다.)
|
|
170
227
|
const successCopy = state === "succeeded" ? String(o.success_copy || "") : "";
|
|
228
|
+
const needsVerify = Boolean(o.verify);
|
|
229
|
+
const vr = needsVerify ? (verifies[o.offer_id] || null) : null;
|
|
230
|
+
const verifyState = !needsVerify ? "" : (vr ? vr.state : "none");
|
|
231
|
+
let verifyText = "";
|
|
232
|
+
if (needsVerify && (verifyState === "none" || verifyState === "loading")) {
|
|
233
|
+
verifyText = messages.ui_action_verify_loading;
|
|
234
|
+
} else if (verifyState === "blocked") {
|
|
235
|
+
verifyText = (vr && vr.denyCopy)
|
|
236
|
+
|| (vr && vr.reasonCode === "precondition_failed" ? messages.ui_action_verify_blocked : messages.ui_action_verify_failed);
|
|
237
|
+
}
|
|
171
238
|
let lockedText = "";
|
|
172
239
|
if (locked) {
|
|
173
240
|
const who = o.lock && o.lock.actorClaimed ? ` · ${o.lock.actorClaimed}` : "";
|
|
@@ -184,6 +251,12 @@ export function actionOffersView({ offers = [], pending = [], enabled = true, co
|
|
|
184
251
|
buttonLabel: messages.ui_action_execute,
|
|
185
252
|
confirming: confirming === o.offer_id,
|
|
186
253
|
confirmText: o.confirm || messages.ui_action_confirm_default,
|
|
254
|
+
// 대상 확정 (T2.7) — verify 동봉 offer 는 읽기 확인이 ok 여야 실행 버튼이 나온다 (위험도 무관)
|
|
255
|
+
needsVerify,
|
|
256
|
+
verifyOk: !needsVerify || verifyState === "ok",
|
|
257
|
+
verifyTitle: messages.ui_action_verify_title,
|
|
258
|
+
verifyLines: vr && Array.isArray(vr.display) ? vr.display.map((f) => `${f.label}: ${f.value}`) : [],
|
|
259
|
+
verifyText,
|
|
187
260
|
needsAck: o.risk_level === "high",
|
|
188
261
|
ackLabel: messages.ui_action_ack,
|
|
189
262
|
okLabel: messages.ui_action_confirm_ok,
|
|
@@ -218,9 +291,17 @@ export function actionOffersTree(input) {
|
|
|
218
291
|
children.push({
|
|
219
292
|
tag: "div", cls: ACTION_CLS.confirm, children: [
|
|
220
293
|
{ tag: "div", cls: ACTION_CLS.confirmText, text: it.confirmText },
|
|
294
|
+
// 대상 확인 블록 (T2.7) — 읽기 결과를 상담사 눈으로 확인한 뒤에만 실행 버튼이 나온다
|
|
295
|
+
...(it.needsVerify ? [{
|
|
296
|
+
tag: "div", cls: ACTION_CLS.verify, children: [
|
|
297
|
+
{ tag: "div", cls: ACTION_CLS.verifyTitle, text: it.verifyTitle },
|
|
298
|
+
...it.verifyLines.map((t) => ({ tag: "div", cls: ACTION_CLS.verifyLine, text: t })),
|
|
299
|
+
...(it.verifyText ? [{ tag: "div", cls: ACTION_CLS.verifyStatus, text: it.verifyText, live: true }] : []),
|
|
300
|
+
],
|
|
301
|
+
}] : []),
|
|
221
302
|
// 고위험 2단 확인 — 토글 **버튼**(aria-pressed): 포커스·Space/Enter 가 되어야 키보드 사용자도 실행할 수 있다
|
|
222
303
|
...(it.needsAck ? [{ tag: "button", type: "button", cls: ACTION_CLS.ack, text: it.ackLabel, action: "ack", offerId: it.offerId, pressed: true }] : []),
|
|
223
|
-
{ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.okLabel, action: "execute", offerId: it.offerId },
|
|
304
|
+
...(it.verifyOk ? [{ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.okLabel, action: "execute", offerId: it.offerId }] : []),
|
|
224
305
|
{ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.cancelLabel, action: "cancel", offerId: it.offerId },
|
|
225
306
|
],
|
|
226
307
|
});
|
|
@@ -237,3 +318,104 @@ export function actionOffersTree(input) {
|
|
|
237
318
|
if (v.disabledHint) items.push({ tag: "div", cls: ACTION_CLS.status, text: v.disabledHint });
|
|
238
319
|
return { tag: "div", cls: ACTION_CLS.root, children: items };
|
|
239
320
|
}
|
|
321
|
+
|
|
322
|
+
// ── 좋아요/싫어요 (0.7.0) — 상담사의 명시적 품질 판단 ─────────────────────
|
|
323
|
+
//
|
|
324
|
+
// 왜 라이브러리에 있나: 이 판단이 절차별 서빙 웨이트·재학습 라벨의 원료다
|
|
325
|
+
// (북극성 "상담사의 매일의 판단이 곧 라벨"). 신호는 기존 scored 5|1 —
|
|
326
|
+
// 새 action·프로토콜 없음. 비소모라 발송 전 판단 변경 가능하고, 집계는
|
|
327
|
+
// 같은 응답 최신본 우선이라 마지막 판단이 이긴다.
|
|
328
|
+
|
|
329
|
+
export const FB_CLS = {
|
|
330
|
+
root: "fcx-fb",
|
|
331
|
+
label: "fcx-fb-label",
|
|
332
|
+
up: "fcx-fb-up",
|
|
333
|
+
down: "fcx-fb-down",
|
|
334
|
+
done: "fcx-fb-done",
|
|
335
|
+
why: "fcx-fb-why",
|
|
336
|
+
whyLabel: "fcx-fb-why-label",
|
|
337
|
+
reason: "fcx-fb-reason",
|
|
338
|
+
skip: "fcx-fb-skip",
|
|
339
|
+
note: "fcx-fb-note",
|
|
340
|
+
noteInput: "fcx-fb-note-input",
|
|
341
|
+
noteSend: "fcx-fb-note-send",
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** 무엇을 그릴지 — 초안(answerId)이 없으면 판단 대상이 없다(null). */
|
|
345
|
+
/** 기각 사유 코드 — 서버 enum(0057)과 같은 키. 값 판정은 서버가 한다. */
|
|
346
|
+
export const FB_REASONS = [
|
|
347
|
+
"wrong_manual", "factual_error", "missing_answer",
|
|
348
|
+
"stale_period", "tone_style", "policy_risk", "other",
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
export function feedbackBarView({ answerId, voted, asking, noting, messages = {} }) {
|
|
352
|
+
if (!answerId) return null;
|
|
353
|
+
const v = voted === "up" || voted === "down" ? voted : null;
|
|
354
|
+
return {
|
|
355
|
+
label: messages.ui_feedback_ask,
|
|
356
|
+
upLabel: messages.ui_feedback_up,
|
|
357
|
+
downLabel: messages.ui_feedback_down,
|
|
358
|
+
doneText: v ? messages.ui_feedback_done : "",
|
|
359
|
+
voted: v,
|
|
360
|
+
/*
|
|
361
|
+
* 싫어요 뒤에만 사유를 묻는다 (0.7.0). 1클릭이 기본이고 사유는 선택 —
|
|
362
|
+
* 관행(Open WebUI RateComment·Claude·Zendesk)이 공통으로 그렇고, 강제하면
|
|
363
|
+
* 상담사가 싫어요 자체를 안 누른다(신호가 통째로 사라진다).
|
|
364
|
+
*/
|
|
365
|
+
asking: Boolean(asking) && v === "down",
|
|
366
|
+
/*
|
|
367
|
+
* 「기타」는 코드만으로는 뜻이 없다 — 무엇이 문제였는지 못 읽으면 그 신호는
|
|
368
|
+
* 집계 숫자로만 남고 개선에 못 쓴다(2026-08-27 사용자 지적). 그래서 기타를
|
|
369
|
+
* 고르면 한 줄 적는 칸이 열린다: 코드는 other, 문장은 note 로 함께 간다.
|
|
370
|
+
*/
|
|
371
|
+
noting: Boolean(noting) && v === "down",
|
|
372
|
+
whyLabel: messages.ui_feedback_why,
|
|
373
|
+
skipLabel: messages.ui_feedback_skip,
|
|
374
|
+
notePlaceholder: messages.ui_feedback_note_hint,
|
|
375
|
+
noteSendLabel: messages.ui_feedback_note_send,
|
|
376
|
+
reasons: FB_REASONS.map((code) => ({ code, label: messages[`reason_${code}`] || code })),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 트리 스펙 — 어댑터가 action "up"/"down" 을 자기 이벤트로 배선한다. */
|
|
381
|
+
export function feedbackBarTree(input) {
|
|
382
|
+
const v = feedbackBarView(input);
|
|
383
|
+
if (!v) return null;
|
|
384
|
+
return {
|
|
385
|
+
tag: "div", cls: FB_CLS.root,
|
|
386
|
+
children: [
|
|
387
|
+
{ tag: "span", cls: FB_CLS.label, text: v.label },
|
|
388
|
+
{ tag: "button", cls: FB_CLS.up, type: "button",
|
|
389
|
+
text: v.upLabel, action: "up", pressed: v.voted === "up" },
|
|
390
|
+
{ tag: "button", cls: FB_CLS.down, type: "button",
|
|
391
|
+
text: v.downLabel, action: "down", pressed: v.voted === "down" },
|
|
392
|
+
...(v.doneText && !v.asking
|
|
393
|
+
? [{ tag: "span", cls: FB_CLS.done, text: v.doneText, live: true }]
|
|
394
|
+
: []),
|
|
395
|
+
...(v.noting
|
|
396
|
+
? [{
|
|
397
|
+
tag: "div", cls: FB_CLS.note,
|
|
398
|
+
children: [
|
|
399
|
+
{ tag: "input", cls: FB_CLS.noteInput, action: "note",
|
|
400
|
+
placeholder: v.notePlaceholder },
|
|
401
|
+
{ tag: "button", cls: FB_CLS.noteSend, type: "button",
|
|
402
|
+
text: v.noteSendLabel, action: "noteSend" },
|
|
403
|
+
],
|
|
404
|
+
}]
|
|
405
|
+
: []),
|
|
406
|
+
...(v.asking && !v.noting
|
|
407
|
+
? [{
|
|
408
|
+
tag: "div", cls: FB_CLS.why,
|
|
409
|
+
children: [
|
|
410
|
+
{ tag: "span", cls: FB_CLS.whyLabel, text: v.whyLabel },
|
|
411
|
+
...v.reasons.map((r) => ({
|
|
412
|
+
tag: "button", cls: FB_CLS.reason, type: "button",
|
|
413
|
+
text: r.label, action: "reason", reason: r.code,
|
|
414
|
+
})),
|
|
415
|
+
{ tag: "button", cls: FB_CLS.skip, type: "button", text: v.skipLabel, action: "skip" },
|
|
416
|
+
],
|
|
417
|
+
}]
|
|
418
|
+
: []),
|
|
419
|
+
],
|
|
420
|
+
};
|
|
421
|
+
}
|
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 };
|