@fcg-labs/cx-agent-hook 0.6.0 → 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 +22 -0
- package/client.js +7 -4
- package/element.js +6 -0
- package/locales.js +72 -0
- package/package.json +1 -1
- package/react.d.ts +9 -0
- package/react.js +160 -13
- package/session.js +48 -6
- package/styles.css +86 -0
- package/view.d.ts +15 -0
- package/view.js +162 -9
package/MIGRATION.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
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
|
+
|
|
1
23
|
# 0.5.x → 0.6.0 마이그레이션
|
|
2
24
|
|
|
3
25
|
## 요약 — 대상 확정(더블체크, 계약 §B.10)이 추가된다. **깨지는 것 없음** (additive)
|
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;
|
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",
|
|
@@ -76,8 +94,26 @@ const ko = {
|
|
|
76
94
|
ui_request: "AI 답변 제안",
|
|
77
95
|
ui_requesting: "제안 생성 중...",
|
|
78
96
|
ui_adopt: "에디터에 넣기",
|
|
97
|
+
ui_feedback_ask: "이 초안, 쓸 만했나요?",
|
|
98
|
+
ui_feedback_up: "좋아요",
|
|
99
|
+
ui_feedback_down: "싫어요",
|
|
100
|
+
ui_feedback_done: "기록됐습니다 — 다음 제안 품질에 반영됩니다.",
|
|
79
101
|
ui_evidence: "근거",
|
|
80
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: "기타",
|
|
81
117
|
ui_action_execute: "처리하기",
|
|
82
118
|
ui_action_confirm_default: "이 처리를 진행할까요?",
|
|
83
119
|
ui_action_confirm_ok: "네, 진행",
|
|
@@ -118,8 +154,26 @@ const ja = {
|
|
|
118
154
|
ui_request: "AI 返信案",
|
|
119
155
|
ui_requesting: "生成中...",
|
|
120
156
|
ui_adopt: "エディタに挿入",
|
|
157
|
+
ui_feedback_ask: "この下書きは役に立ちましたか?",
|
|
158
|
+
ui_feedback_up: "良い",
|
|
159
|
+
ui_feedback_down: "悪い",
|
|
160
|
+
ui_feedback_done: "記録しました — 今後の提案に反映されます。",
|
|
121
161
|
ui_evidence: "根拠",
|
|
122
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: "その他",
|
|
123
177
|
ui_action_execute: "処理する",
|
|
124
178
|
ui_action_confirm_default: "この処理を実行しますか?",
|
|
125
179
|
ui_action_confirm_ok: "はい、実行",
|
|
@@ -160,8 +214,26 @@ const zhTW = {
|
|
|
160
214
|
ui_request: "AI 回覆建議",
|
|
161
215
|
ui_requesting: "產生中...",
|
|
162
216
|
ui_adopt: "插入編輯器",
|
|
217
|
+
ui_feedback_ask: "這份草稿有幫助嗎?",
|
|
218
|
+
ui_feedback_up: "有幫助",
|
|
219
|
+
ui_feedback_down: "沒幫助",
|
|
220
|
+
ui_feedback_done: "已記錄 — 將反映於後續建議。",
|
|
163
221
|
ui_evidence: "依據",
|
|
164
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: "其他",
|
|
165
237
|
ui_action_execute: "處理",
|
|
166
238
|
ui_action_confirm_default: "要執行這項處理嗎?",
|
|
167
239
|
ui_action_confirm_ok: "是,執行",
|
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
|
|
|
@@ -183,3 +234,99 @@ export function ActionOffersPanel({ session, agent, actorClaimed, onResult, clas
|
|
|
183
234
|
onClick: (event) => event.stopPropagation() },
|
|
184
235
|
tree.children.map((n, i) => render(n, i)));
|
|
185
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
|
@@ -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)` 가 만든다.
|
|
@@ -457,6 +465,27 @@ export class InquirySession {
|
|
|
457
465
|
};
|
|
458
466
|
const clearPending = () => {
|
|
459
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);
|
|
460
489
|
};
|
|
461
490
|
|
|
462
491
|
this._state = "composing";
|
|
@@ -468,7 +497,17 @@ export class InquirySession {
|
|
|
468
497
|
signal: controller.signal,
|
|
469
498
|
...(idleTimeoutMs ? { idleTimeoutMs } : {}),
|
|
470
499
|
...(overallTimeoutMs ? { overallTimeoutMs } : {}),
|
|
471
|
-
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
|
+
},
|
|
472
511
|
onRestart: () => {
|
|
473
512
|
// 계약 위반 교정 — 지금까지 보인 초안은 폐기본이다
|
|
474
513
|
clearPending();
|
|
@@ -478,7 +517,10 @@ export class InquirySession {
|
|
|
478
517
|
this._setStatus(d.messages.ui_correcting);
|
|
479
518
|
},
|
|
480
519
|
onStage: (phase) => {
|
|
481
|
-
|
|
520
|
+
// 단계를 사람 말로 — "제안 생성 중" 한 문구로는 15초 동안 무엇이
|
|
521
|
+
// 진행 중인지 알 수 없다(근거 검색과 생성은 체감이 다른 구간이다).
|
|
522
|
+
if (phase === "retrieving") this._setStatus(d.messages.ui_retrieving);
|
|
523
|
+
else if (phase === "generating") this._setStatus(d.messages.ui_requesting);
|
|
482
524
|
},
|
|
483
525
|
}).then((result) => {
|
|
484
526
|
clearPending();
|
|
@@ -565,7 +607,7 @@ export class InquirySession {
|
|
|
565
607
|
this._emit((id) => this._d.client.sent(id, finalText, agent), true);
|
|
566
608
|
}
|
|
567
609
|
|
|
568
|
-
/** 품질 점수 1~5 — 발송 전 여러 번 가능 (비소모) */
|
|
610
|
+
/** 품질 점수 1~5 — 발송 전 여러 번 가능 (비소모). 사유는 여기 안 실린다(아래 계약). */
|
|
569
611
|
scored(score, agent) {
|
|
570
612
|
this._emit((id) => this._d.client.scored(id, score, agent), false);
|
|
571
613
|
}
|
|
@@ -575,8 +617,8 @@ export class InquirySession {
|
|
|
575
617
|
this._emit((id) => this._d.client.edited(id, finalText, agent), false);
|
|
576
618
|
}
|
|
577
619
|
|
|
578
|
-
/** 폐기 + 이유 — 마지막 판단이라 기록을
|
|
579
|
-
discarded(agent, note) {
|
|
580
|
-
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);
|
|
581
623
|
}
|
|
582
624
|
}
|
package/styles.css
CHANGED
|
@@ -178,3 +178,89 @@
|
|
|
178
178
|
@media (prefers-reduced-motion: reduce) {
|
|
179
179
|
.fcx-ai-button, .fcx-ai-adopt, .fcx-act-button, .fcx-act-resend { transition: none; }
|
|
180
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
|
|
|
@@ -266,3 +318,104 @@ export function actionOffersTree(input) {
|
|
|
266
318
|
if (v.disabledHint) items.push({ tag: "div", cls: ACTION_CLS.status, text: v.disabledHint });
|
|
267
319
|
return { tag: "div", cls: ACTION_CLS.root, children: items };
|
|
268
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
|
+
}
|