@fcg-labs/cx-agent-hook 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +36 -0
- package/agent.d.ts +9 -0
- package/client.d.ts +3 -0
- package/client.js +20 -11
- package/element.js +6 -0
- package/index.d.ts +6 -0
- package/locales.js +84 -0
- package/package.json +1 -1
- package/react.d.ts +18 -0
- package/react.js +194 -13
- package/session.js +94 -8
- package/styles.css +86 -0
- package/view.d.ts +23 -0
- package/view.js +208 -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)
|
|
@@ -192,3 +214,17 @@ headless 2단계: ① 스타일 0 — styles.css 를 import 하지 않는다.
|
|
|
192
214
|
이후 허브 발급분은 문자열이다. 불투명 취급하면 코드 변경은 없다.
|
|
193
215
|
- element 어댑터의 내부 DOM 구조는 동일하나, 어댑터 3종이 `aiSuggestTree`
|
|
194
216
|
인터프리터로 재구현됐다 — 클래스·이벤트·마크업 계약은 시험으로 고정.
|
|
217
|
+
|
|
218
|
+
## 0.7.x → 0.8.0 — 다안 (candidates)
|
|
219
|
+
|
|
220
|
+
전부 **가산적**이라 0.7.x 소비처는 코드 변경 없이 그대로 동작한다.
|
|
221
|
+
|
|
222
|
+
- 응답에 `candidates[]` 가 실린다 — `[0]` 은 항상 본안(`variant: "primary"`,
|
|
223
|
+
`answer` 는 최상위 `answer` 와 동일). 서버 env(`AI_CS_CANDIDATES`)가 꺼져
|
|
224
|
+
있으면 빈 배열이다.
|
|
225
|
+
- 세션: `candidates` / `chosenVariant` getter 와 `choose(index)` — 후보를 초안
|
|
226
|
+
정본으로 바꾼다(데코레이트는 compose 시점에 끝나 있다). sink 에
|
|
227
|
+
`setCandidates(list, chosen)` 를 **선택적으로** 구현하면 도착·선택을 받는다.
|
|
228
|
+
- 발송: 본안이 아닌 후보를 골라 보냈으면 `sent` 피드백의 `note` 에
|
|
229
|
+
`variant:<id>` 가 남는다 — 와이어 계약(허브 Go) 무변경.
|
|
230
|
+
- React: `CandidatePicker` 신규. `AiSuggestPanel` 은 무변경.
|
package/agent.d.ts
CHANGED
|
@@ -81,6 +81,9 @@ export interface ComposeHandle {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export interface SessionUi {
|
|
84
|
+
/** 다안 도착·선택 시 (0.8.0, 선택 구현) — 없으면 조용히 생략된다. */
|
|
85
|
+
setCandidates?(candidates: Array<{ variant: string; label: string; answer: string }>,
|
|
86
|
+
chosen: string): void;
|
|
84
87
|
getDraft?: () => string;
|
|
85
88
|
setDraft: (text: string) => void;
|
|
86
89
|
setStatus?: (text: string) => void;
|
|
@@ -92,6 +95,12 @@ export declare class InquirySession {
|
|
|
92
95
|
readonly state: "idle" | "composing" | "drafted" | "declined";
|
|
93
96
|
readonly draft: string;
|
|
94
97
|
readonly adoptedAnswerId: number | string | null;
|
|
98
|
+
/** 다안 (0.8.0) — 데코레이트 완료본, [0]=본안. */
|
|
99
|
+
readonly candidates: Array<{ variant: string; label: string; answer: string;
|
|
100
|
+
evidence_keys: string[]; contract_warnings: string[] }>;
|
|
101
|
+
readonly chosenVariant: string;
|
|
102
|
+
/** 후보를 초안 정본으로 (0.8.0). 잘못된 index 는 무동작. */
|
|
103
|
+
choose(index: number): void;
|
|
95
104
|
|
|
96
105
|
/** UI sink 연결 — 자동 주입 없음, 복원은 restore() 명시 호출 */
|
|
97
106
|
attachUi(ui: SessionUi): this;
|
package/client.d.ts
CHANGED
|
@@ -38,6 +38,9 @@ export interface AnswerResult {
|
|
|
38
38
|
/** 신원 수술(0048) 이후 허브 발급분은 ULID 문자열 — 불투명 취급 */
|
|
39
39
|
answerId: number | string | null;
|
|
40
40
|
evidence: Array<{ unit_key: string; title: string; score: number }>;
|
|
41
|
+
/** 다안 (0.8.0) — [0]=본안. env 끔·거절이면 []. */
|
|
42
|
+
candidates: Array<{ variant: string; label: string; answer: string;
|
|
43
|
+
evidence_keys: string[]; contract_warnings: string[] }>;
|
|
41
44
|
declinedReason: string;
|
|
42
45
|
raw: Record<string, unknown>;
|
|
43
46
|
}
|
package/client.js
CHANGED
|
@@ -206,7 +206,7 @@ export class CxAgentClient {
|
|
|
206
206
|
);
|
|
207
207
|
return {
|
|
208
208
|
ok: false, answered: false, answer: "", answerId: null,
|
|
209
|
-
evidence: [], declinedReason: "unsupported_api", raw: {},
|
|
209
|
+
evidence: [], candidates: [], declinedReason: "unsupported_api", raw: {},
|
|
210
210
|
};
|
|
211
211
|
}
|
|
212
212
|
try {
|
|
@@ -227,7 +227,7 @@ export class CxAgentClient {
|
|
|
227
227
|
// 공장 JSONResponse({"error": "serving_disabled"}, 503) 등
|
|
228
228
|
return {
|
|
229
229
|
ok: false, answered: false, answer: "", answerId: null,
|
|
230
|
-
evidence: [],
|
|
230
|
+
evidence: [], candidates: [],
|
|
231
231
|
declinedReason: (data && data.error) || `http_${status}`,
|
|
232
232
|
raw: data,
|
|
233
233
|
};
|
|
@@ -238,6 +238,7 @@ export class CxAgentClient {
|
|
|
238
238
|
answer: data.answer || "",
|
|
239
239
|
answerId: data.answer_id ?? null,
|
|
240
240
|
evidence: data.evidence || [],
|
|
241
|
+
candidates: Array.isArray(data.candidates) ? data.candidates : [],
|
|
241
242
|
declinedReason: data.declined_reason || "",
|
|
242
243
|
raw: data,
|
|
243
244
|
};
|
|
@@ -245,7 +246,7 @@ export class CxAgentClient {
|
|
|
245
246
|
this.onError(err, { op: "getAnswer" });
|
|
246
247
|
return {
|
|
247
248
|
ok: false, answered: false, answer: "", answerId: null,
|
|
248
|
-
evidence: [], declinedReason: "network_error", raw: {},
|
|
249
|
+
evidence: [], candidates: [], declinedReason: "network_error", raw: {},
|
|
249
250
|
};
|
|
250
251
|
}
|
|
251
252
|
}
|
|
@@ -276,7 +277,7 @@ export class CxAgentClient {
|
|
|
276
277
|
} = {}) {
|
|
277
278
|
const fail = (reason, raw = {}) => ({
|
|
278
279
|
ok: false, answered: false, answer: "", answerId: null,
|
|
279
|
-
evidence: [], declinedReason: reason, raw,
|
|
280
|
+
evidence: [], candidates: [], declinedReason: reason, raw,
|
|
280
281
|
});
|
|
281
282
|
const target = this._target("answer");
|
|
282
283
|
const streamPath = PATHS[target.api] && PATHS[target.api].answerStream;
|
|
@@ -361,6 +362,7 @@ export class CxAgentClient {
|
|
|
361
362
|
answer: final.answer || "",
|
|
362
363
|
answerId: final.answer_id ?? null,
|
|
363
364
|
evidence: final.evidence || [],
|
|
365
|
+
candidates: Array.isArray(final.candidates) ? final.candidates : [],
|
|
364
366
|
declinedReason: final.declined_reason || "",
|
|
365
367
|
raw: final,
|
|
366
368
|
};
|
|
@@ -387,7 +389,7 @@ export class CxAgentClient {
|
|
|
387
389
|
* @param {string} [fb.note]
|
|
388
390
|
* @returns {Promise<{ok:boolean, feedbackId:number|null, error:string}>}
|
|
389
391
|
*/
|
|
390
|
-
async sendFeedback({ answerId, action, score, finalText, agent, note } = {}) {
|
|
392
|
+
async sendFeedback({ answerId, action, score, finalText, agent, note, reasonCode } = {}) {
|
|
391
393
|
// 계약 위반은 재시도해도 소용없다 — 즉시 보고
|
|
392
394
|
if (answerId === undefined || answerId === null || answerId === "") {
|
|
393
395
|
return this._fbFail(new Error("answerId 필수"), "invalid_answer_id");
|
|
@@ -411,6 +413,9 @@ export class CxAgentClient {
|
|
|
411
413
|
...(finalText ? { final_text: finalText } : {}),
|
|
412
414
|
...(agent ? { agent } : {}),
|
|
413
415
|
...(note ? { note } : {}),
|
|
416
|
+
// 사유 코드는 값 판정을 하지 않고 그대로 운반한다 — enum 정본은 서버(0057).
|
|
417
|
+
// 모르는 값이면 서버가 거절하고, 그 사실이 드러나는 게 조용한 소실보다 낫다.
|
|
418
|
+
...(reasonCode ? { reason_code: reasonCode } : {}),
|
|
414
419
|
};
|
|
415
420
|
const path = target.path(this.domain);
|
|
416
421
|
let lastErr = null;
|
|
@@ -442,9 +447,13 @@ export class CxAgentClient {
|
|
|
442
447
|
return { ok: false, feedbackId: null, error: code };
|
|
443
448
|
}
|
|
444
449
|
|
|
445
|
-
/** 발송 후킹 — CS팀이 실제로 내보낸 최종 문구 (gold 학습 쌍의 원천)
|
|
446
|
-
|
|
447
|
-
|
|
450
|
+
/** 발송 후킹 — CS팀이 실제로 내보낸 최종 문구 (gold 학습 쌍의 원천).
|
|
451
|
+
* note: 다안(0.8.0)에서 선택 변형 표식 `variant:<id>` — 와이어 계약 무변경. */
|
|
452
|
+
sent(answerId, finalText, agent, note = "") {
|
|
453
|
+
return this.sendFeedback({
|
|
454
|
+
answerId, action: "sent", finalText, agent,
|
|
455
|
+
...(note ? { note } : {}),
|
|
456
|
+
});
|
|
448
457
|
}
|
|
449
458
|
|
|
450
459
|
/** 점수 후킹 (1~5) */
|
|
@@ -457,9 +466,9 @@ export class CxAgentClient {
|
|
|
457
466
|
return this.sendFeedback({ answerId, action: "edited", finalText, agent });
|
|
458
467
|
}
|
|
459
468
|
|
|
460
|
-
/** 폐기 후킹 — 품질 경보
|
|
461
|
-
discarded(answerId, agent, note) {
|
|
462
|
-
return this.sendFeedback({ answerId, action: "discarded", agent, note });
|
|
469
|
+
/** 폐기 후킹 — 품질 경보 신호. reasonCode 는 서버 enum(0057) 그대로 운반한다. */
|
|
470
|
+
discarded(answerId, agent, note, reasonCode) {
|
|
471
|
+
return this.sendFeedback({ answerId, action: "discarded", agent, note, reasonCode });
|
|
463
472
|
}
|
|
464
473
|
|
|
465
474
|
/**
|
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/index.d.ts
CHANGED
|
@@ -75,6 +75,12 @@ export interface CxHook {
|
|
|
75
75
|
/** 채택 기록 폐기 — 문의 전환 시 */
|
|
76
76
|
clearAdopted(): void;
|
|
77
77
|
readonly adoptedAnswerId: number | string | null;
|
|
78
|
+
/** 다안 (0.8.0) — 데코레이트 완료본, [0]=본안. */
|
|
79
|
+
readonly candidates: Array<{ variant: string; label: string; answer: string;
|
|
80
|
+
evidence_keys: string[]; contract_warnings: string[] }>;
|
|
81
|
+
readonly chosenVariant: string;
|
|
82
|
+
/** 후보를 초안 정본으로 (0.8.0). 잘못된 index 는 무동작. */
|
|
83
|
+
choose(index: number): void;
|
|
78
84
|
|
|
79
85
|
/** 발송됨 — 채택본이었을 때만 gold 쌍 후킹. 보낸 뒤 채택 기록을 비운다. */
|
|
80
86
|
answerSent(finalText: string, agent?: string): void;
|
package/locales.js
CHANGED
|
@@ -29,8 +29,29 @@ const en = {
|
|
|
29
29
|
ui_request: "Suggest a reply",
|
|
30
30
|
ui_requesting: "Generating…",
|
|
31
31
|
ui_adopt: "Insert into editor",
|
|
32
|
+
ui_candidates: "AI draft options",
|
|
33
|
+
ui_candidate_use: "Use this draft",
|
|
34
|
+
ui_candidate_chosen: "Selected",
|
|
35
|
+
ui_feedback_ask: "Was this draft helpful?",
|
|
36
|
+
ui_feedback_up: "Helpful",
|
|
37
|
+
ui_feedback_down: "Not helpful",
|
|
38
|
+
ui_feedback_done: "Recorded — it will tune future suggestions.",
|
|
32
39
|
ui_evidence: "Sources",
|
|
33
40
|
ui_correcting: "Fixing phrasing — rewriting…",
|
|
41
|
+
ui_retrieving: "Finding sources…",
|
|
42
|
+
ui_ai_label: "AI draft",
|
|
43
|
+
ui_ai_disclaimer: "AI-generated — review before sending.",
|
|
44
|
+
ui_feedback_why: "What was wrong?",
|
|
45
|
+
ui_feedback_skip: "Skip",
|
|
46
|
+
ui_feedback_note_hint: "What was wrong? (one line)",
|
|
47
|
+
ui_feedback_note_send: "Send",
|
|
48
|
+
reason_stale_period: "Outdated",
|
|
49
|
+
reason_wrong_manual: "Wrong source",
|
|
50
|
+
reason_factual_error: "Incorrect",
|
|
51
|
+
reason_missing_answer: "Incomplete",
|
|
52
|
+
reason_tone_style: "Tone",
|
|
53
|
+
reason_policy_risk: "Risky wording",
|
|
54
|
+
reason_other: "Other",
|
|
34
55
|
ui_action_execute: "Process",
|
|
35
56
|
ui_action_confirm_default: "Run this action?",
|
|
36
57
|
ui_action_confirm_ok: "Yes, run",
|
|
@@ -76,8 +97,29 @@ const ko = {
|
|
|
76
97
|
ui_request: "AI 답변 제안",
|
|
77
98
|
ui_requesting: "제안 생성 중...",
|
|
78
99
|
ui_adopt: "에디터에 넣기",
|
|
100
|
+
ui_candidates: "AI 답변 후보",
|
|
101
|
+
ui_candidate_use: "이 안 쓰기",
|
|
102
|
+
ui_candidate_chosen: "선택됨",
|
|
103
|
+
ui_feedback_ask: "이 초안, 쓸 만했나요?",
|
|
104
|
+
ui_feedback_up: "좋아요",
|
|
105
|
+
ui_feedback_down: "싫어요",
|
|
106
|
+
ui_feedback_done: "기록됐습니다 — 다음 제안 품질에 반영됩니다.",
|
|
79
107
|
ui_evidence: "근거",
|
|
80
108
|
ui_correcting: "표현 교정 중 — 다시 쓰는 중...",
|
|
109
|
+
ui_retrieving: "근거 찾는 중…",
|
|
110
|
+
ui_ai_label: "AI 초안",
|
|
111
|
+
ui_ai_disclaimer: "AI가 쓴 초안입니다 — 확인 후 전송하세요.",
|
|
112
|
+
ui_feedback_why: "무엇이 문제였나요?",
|
|
113
|
+
ui_feedback_skip: "건너뛰기",
|
|
114
|
+
ui_feedback_note_hint: "무엇이 문제였나요? (한 줄)",
|
|
115
|
+
ui_feedback_note_send: "보내기",
|
|
116
|
+
reason_stale_period: "낡은 내용",
|
|
117
|
+
reason_wrong_manual: "다른 근거",
|
|
118
|
+
reason_factual_error: "내용 오류",
|
|
119
|
+
reason_missing_answer: "일부 미답",
|
|
120
|
+
reason_tone_style: "어조·표현",
|
|
121
|
+
reason_policy_risk: "위험 표현",
|
|
122
|
+
reason_other: "기타",
|
|
81
123
|
ui_action_execute: "처리하기",
|
|
82
124
|
ui_action_confirm_default: "이 처리를 진행할까요?",
|
|
83
125
|
ui_action_confirm_ok: "네, 진행",
|
|
@@ -118,8 +160,29 @@ const ja = {
|
|
|
118
160
|
ui_request: "AI 返信案",
|
|
119
161
|
ui_requesting: "生成中...",
|
|
120
162
|
ui_adopt: "エディタに挿入",
|
|
163
|
+
ui_candidates: "AI回答の候補",
|
|
164
|
+
ui_candidate_use: "この案を使う",
|
|
165
|
+
ui_candidate_chosen: "選択中",
|
|
166
|
+
ui_feedback_ask: "この下書きは役に立ちましたか?",
|
|
167
|
+
ui_feedback_up: "良い",
|
|
168
|
+
ui_feedback_down: "悪い",
|
|
169
|
+
ui_feedback_done: "記録しました — 今後の提案に反映されます。",
|
|
121
170
|
ui_evidence: "根拠",
|
|
122
171
|
ui_correcting: "表現を修正中 — 書き直しています…",
|
|
172
|
+
ui_retrieving: "根拠を検索中…",
|
|
173
|
+
ui_ai_label: "AI 下書き",
|
|
174
|
+
ui_ai_disclaimer: "AI が作成した下書きです — 確認してから送信してください。",
|
|
175
|
+
ui_feedback_why: "何が問題でしたか?",
|
|
176
|
+
ui_feedback_skip: "スキップ",
|
|
177
|
+
ui_feedback_note_hint: "何が問題でしたか?(一行)",
|
|
178
|
+
ui_feedback_note_send: "送信",
|
|
179
|
+
reason_stale_period: "古い内容",
|
|
180
|
+
reason_wrong_manual: "根拠が違う",
|
|
181
|
+
reason_factual_error: "内容の誤り",
|
|
182
|
+
reason_missing_answer: "一部未回答",
|
|
183
|
+
reason_tone_style: "語調・表現",
|
|
184
|
+
reason_policy_risk: "リスク表現",
|
|
185
|
+
reason_other: "その他",
|
|
123
186
|
ui_action_execute: "処理する",
|
|
124
187
|
ui_action_confirm_default: "この処理を実行しますか?",
|
|
125
188
|
ui_action_confirm_ok: "はい、実行",
|
|
@@ -159,9 +222,30 @@ const ja = {
|
|
|
159
222
|
const zhTW = {
|
|
160
223
|
ui_request: "AI 回覆建議",
|
|
161
224
|
ui_requesting: "產生中...",
|
|
225
|
+
ui_candidates: "AI 回覆候選",
|
|
226
|
+
ui_candidate_use: "使用此草稿",
|
|
227
|
+
ui_candidate_chosen: "已選擇",
|
|
162
228
|
ui_adopt: "插入編輯器",
|
|
229
|
+
ui_feedback_ask: "這份草稿有幫助嗎?",
|
|
230
|
+
ui_feedback_up: "有幫助",
|
|
231
|
+
ui_feedback_down: "沒幫助",
|
|
232
|
+
ui_feedback_done: "已記錄 — 將反映於後續建議。",
|
|
163
233
|
ui_evidence: "依據",
|
|
164
234
|
ui_correcting: "正在修正表述 — 重新撰寫中…",
|
|
235
|
+
ui_retrieving: "尋找依據中…",
|
|
236
|
+
ui_ai_label: "AI 草稿",
|
|
237
|
+
ui_ai_disclaimer: "AI 產生的草稿 — 請確認後再傳送。",
|
|
238
|
+
ui_feedback_why: "哪裡有問題?",
|
|
239
|
+
ui_feedback_skip: "略過",
|
|
240
|
+
ui_feedback_note_hint: "哪裡有問題?(一行)",
|
|
241
|
+
ui_feedback_note_send: "送出",
|
|
242
|
+
reason_stale_period: "內容過時",
|
|
243
|
+
reason_wrong_manual: "依據不符",
|
|
244
|
+
reason_factual_error: "內容錯誤",
|
|
245
|
+
reason_missing_answer: "部分未答",
|
|
246
|
+
reason_tone_style: "語氣表達",
|
|
247
|
+
reason_policy_risk: "風險用語",
|
|
248
|
+
reason_other: "其他",
|
|
165
249
|
ui_action_execute: "處理",
|
|
166
250
|
ui_action_confirm_default: "要執行這項處理嗎?",
|
|
167
251
|
ui_action_confirm_ok: "是,執行",
|
package/package.json
CHANGED
package/react.d.ts
CHANGED
|
@@ -31,4 +31,22 @@ 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;
|
|
44
|
+
|
|
45
|
+
/** 다안 피커 (0.8.0) — 후보 2안 미만이면 null 렌더. 선택 정본은 세션(choose). */
|
|
46
|
+
export declare function CandidatePicker(props: {
|
|
47
|
+
candidates: Array<{ variant: string; label: string; answer: string }>;
|
|
48
|
+
chosen: string;
|
|
49
|
+
messages: Record<string, string>;
|
|
50
|
+
onChoose: (index: number) => void;
|
|
51
|
+
className?: string;
|
|
52
|
+
}): unknown;
|
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, candidatePickerTree, 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,133 @@ 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
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
// ── 다안 피커 (0.8.0) ────────────────────────────────────────────────────────
|
|
336
|
+
//
|
|
337
|
+
// ```jsx
|
|
338
|
+
// <CandidatePicker candidates={session.candidates} chosen={session.chosenVariant}
|
|
339
|
+
// messages={cxMessages} onChoose={(i) => session.choose(i)} />
|
|
340
|
+
// ```
|
|
341
|
+
// 표시 판단은 view.candidatePickerTree 가 소유한다 — 2안 미만이면 아무것도
|
|
342
|
+
// 그리지 않는다. 선택은 세션(choose)이 정본이라 이 컴포넌트는 상태가 없다.
|
|
343
|
+
|
|
344
|
+
export function CandidatePicker({ candidates, chosen, messages, onChoose, className }) {
|
|
345
|
+
const tree = candidatePickerTree({ candidates, chosen, messages });
|
|
346
|
+
if (!tree) return null;
|
|
347
|
+
const render = (node, key) => {
|
|
348
|
+
const props = { key, className: node.cls };
|
|
349
|
+
if (node.tag === "button") {
|
|
350
|
+
props.type = node.type || "button";
|
|
351
|
+
if (node.action === "choose" && onChoose) {
|
|
352
|
+
props.onClick = () => onChoose(node.index);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const children = node.children
|
|
356
|
+
? node.children.map((c, i) => render(c, i))
|
|
357
|
+
: node.text;
|
|
358
|
+
return h(node.tag, props, children);
|
|
359
|
+
};
|
|
360
|
+
return h(
|
|
361
|
+
"div",
|
|
362
|
+
{ className: className ? `${tree.cls} ${className}` : tree.cls,
|
|
363
|
+
onClick: (event) => event.stopPropagation() }, // 문의 행 선택 토글 방지 (패널 선례)
|
|
364
|
+
tree.children.map((c, i) => render(c, i)),
|
|
365
|
+
);
|
|
366
|
+
}
|
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)` 가 만든다.
|
|
@@ -158,6 +166,9 @@ export class InquirySession {
|
|
|
158
166
|
this._locks = {}; // action_key\x00target_key → 잠금 조회 결과 (세션 캐시)
|
|
159
167
|
this._verify = {}; // offer_id → 대상 확정(더블체크) 결과 (T2.7, 화면 표시용 캐시)
|
|
160
168
|
|
|
169
|
+
this._candidates = []; // 다안 (0.8.0) — 데코레이트 완료본, [0]=본안
|
|
170
|
+
this._chosenVariant = "primary";
|
|
171
|
+
|
|
161
172
|
// 영속 복원 — TTL 만료분은 store 가 이미 걸렀다
|
|
162
173
|
if (this._id && deps.store) {
|
|
163
174
|
const rec = deps.store.load(this._id);
|
|
@@ -166,6 +177,8 @@ export class InquirySession {
|
|
|
166
177
|
this._answerId = rec.answerId ?? null;
|
|
167
178
|
this._state = rec.state === "declined" ? "declined"
|
|
168
179
|
: (this._draft || this._answerId ? "drafted" : "idle");
|
|
180
|
+
this._candidates = Array.isArray(rec.candidates) ? rec.candidates : [];
|
|
181
|
+
this._chosenVariant = rec.chosenVariant || "primary";
|
|
169
182
|
}
|
|
170
183
|
}
|
|
171
184
|
}
|
|
@@ -350,6 +363,26 @@ export class InquirySession {
|
|
|
350
363
|
get state() { return this._state; }
|
|
351
364
|
get draft() { return this._draft; }
|
|
352
365
|
get adoptedAnswerId() { return this._answerId; }
|
|
366
|
+
get candidates() { return this._candidates.slice(); }
|
|
367
|
+
get chosenVariant() { return this._chosenVariant; }
|
|
368
|
+
|
|
369
|
+
/** 다안 선택 (0.8.0) — 후보를 초안 정본으로. 판단 기록은 없다(고르기는 판단 전 단계). */
|
|
370
|
+
choose(index) {
|
|
371
|
+
const candidate = this._candidates[index];
|
|
372
|
+
if (!candidate) return;
|
|
373
|
+
this._draft = String(candidate.answer || "");
|
|
374
|
+
this._chosenVariant = String(candidate.variant || "primary");
|
|
375
|
+
this._setUiDraft(this._draft);
|
|
376
|
+
this._setUiCandidates();
|
|
377
|
+
this._persist();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
_setUiCandidates() {
|
|
381
|
+
// 선택 메서드 — 없는 sink(구 소비처)는 조용히 넘어간다 (0.7.x 호환)
|
|
382
|
+
if (this._ui && typeof this._ui.setCandidates === "function") {
|
|
383
|
+
this._ui.setCandidates(this._candidates.slice(), this._chosenVariant);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
353
386
|
|
|
354
387
|
/** UI sink 연결 — 초안을 자동 주입하지 않는다 (복원은 restore() 명시 호출).
|
|
355
388
|
* @param {{getDraft?:()=>string, setDraft:(t:string)=>void,
|
|
@@ -370,6 +403,7 @@ export class InquirySession {
|
|
|
370
403
|
if (this._ui) {
|
|
371
404
|
this._ui.setDraft(this._draft);
|
|
372
405
|
if (this._ui.setStatus) this._ui.setStatus(this._statusText);
|
|
406
|
+
this._setUiCandidates();
|
|
373
407
|
}
|
|
374
408
|
return { draft: this._draft, answerId: this._answerId, state: this._state };
|
|
375
409
|
}
|
|
@@ -385,6 +419,7 @@ export class InquirySession {
|
|
|
385
419
|
if (this._id && this._d.store) {
|
|
386
420
|
this._d.store.save(this._id, {
|
|
387
421
|
draft: this._draft, answerId: this._answerId, state: this._state,
|
|
422
|
+
candidates: this._candidates, chosenVariant: this._chosenVariant,
|
|
388
423
|
});
|
|
389
424
|
}
|
|
390
425
|
}
|
|
@@ -457,6 +492,27 @@ export class InquirySession {
|
|
|
457
492
|
};
|
|
458
493
|
const clearPending = () => {
|
|
459
494
|
if (pending !== null) { clearTimeout(pending); pending = null; }
|
|
495
|
+
if (reveal !== null) { clearInterval(reveal); reveal = null; }
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
/*
|
|
499
|
+
* 의사 스트림 (0.7.0) — 공급자가 통짜로 줄 때도 글자가 흐르게.
|
|
500
|
+
*
|
|
501
|
+
* codex 계열은 답변을 **한 번에** 낸다(finish_reason='single_chunk') — 그대로
|
|
502
|
+
* 그리면 15초 정적 뒤 완성본이 튀어나와 "AI 가 쓰고 있다"는 신호가 0 이다.
|
|
503
|
+
* 진짜 토큰 스트림이면 이 경로는 타지 않는다(작은 청크는 그대로 흐른다).
|
|
504
|
+
* 정본(this._draft)은 늘 전체 텍스트다 — 화면에 보이는 양만 늦춘다.
|
|
505
|
+
*/
|
|
506
|
+
let reveal = null;
|
|
507
|
+
let revealed = 0;
|
|
508
|
+
const revealFrom = (full) => {
|
|
509
|
+
if (reveal !== null) { clearInterval(reveal); reveal = null; }
|
|
510
|
+
const step = Math.max(REVEAL_MIN_STEP, Math.ceil(full.length / REVEAL_TICKS));
|
|
511
|
+
reveal = setInterval(() => {
|
|
512
|
+
revealed = Math.min(full.length, revealed + step);
|
|
513
|
+
if (this._ui) this._ui.setDraft(decorate(full.slice(0, revealed)));
|
|
514
|
+
if (revealed >= full.length) { clearInterval(reveal); reveal = null; }
|
|
515
|
+
}, REVEAL_INTERVAL_MS);
|
|
460
516
|
};
|
|
461
517
|
|
|
462
518
|
this._state = "composing";
|
|
@@ -468,7 +524,17 @@ export class InquirySession {
|
|
|
468
524
|
signal: controller.signal,
|
|
469
525
|
...(idleTimeoutMs ? { idleTimeoutMs } : {}),
|
|
470
526
|
...(overallTimeoutMs ? { overallTimeoutMs } : {}),
|
|
471
|
-
onDelta: (text) => {
|
|
527
|
+
onDelta: (text) => {
|
|
528
|
+
accumulated += text;
|
|
529
|
+
// 통짜 청크(공급자가 한 번에 준 것)면 조각내어 흘린다 — 판정은 길이뿐이라
|
|
530
|
+
// 결정적이고, 진짜 스트림의 작은 청크는 기존 스로틀 경로 그대로.
|
|
531
|
+
if (text.length >= BULK_CHUNK_CHARS) {
|
|
532
|
+
this._draft = decorate(accumulated);
|
|
533
|
+
revealFrom(accumulated);
|
|
534
|
+
} else {
|
|
535
|
+
queueDraft();
|
|
536
|
+
}
|
|
537
|
+
},
|
|
472
538
|
onRestart: () => {
|
|
473
539
|
// 계약 위반 교정 — 지금까지 보인 초안은 폐기본이다
|
|
474
540
|
clearPending();
|
|
@@ -478,7 +544,10 @@ export class InquirySession {
|
|
|
478
544
|
this._setStatus(d.messages.ui_correcting);
|
|
479
545
|
},
|
|
480
546
|
onStage: (phase) => {
|
|
481
|
-
|
|
547
|
+
// 단계를 사람 말로 — "제안 생성 중" 한 문구로는 15초 동안 무엇이
|
|
548
|
+
// 진행 중인지 알 수 없다(근거 검색과 생성은 체감이 다른 구간이다).
|
|
549
|
+
if (phase === "retrieving") this._setStatus(d.messages.ui_retrieving);
|
|
550
|
+
else if (phase === "generating") this._setStatus(d.messages.ui_requesting);
|
|
482
551
|
},
|
|
483
552
|
}).then((result) => {
|
|
484
553
|
clearPending();
|
|
@@ -505,6 +574,12 @@ export class InquirySession {
|
|
|
505
574
|
d.client.discarded(supersededId, "", "redraft");
|
|
506
575
|
}
|
|
507
576
|
this._answerId = newId;
|
|
577
|
+
// 다안 (0.8.0) — 지금 데코레이트해 둔다: 이름은 compose 순간의 재료라
|
|
578
|
+
// 나중에 choose() 가 다시 만들 수 없다. 서버가 후보를 안 보내면 [].
|
|
579
|
+
this._candidates = (Array.isArray(result.candidates) ? result.candidates : [])
|
|
580
|
+
.map((c) => ({ ...c, answer: decorate(String(c.answer || "")) }));
|
|
581
|
+
this._chosenVariant = "primary";
|
|
582
|
+
this._setUiCandidates();
|
|
508
583
|
this._state = "drafted";
|
|
509
584
|
this._setStatus("");
|
|
510
585
|
this._persist();
|
|
@@ -514,6 +589,10 @@ export class InquirySession {
|
|
|
514
589
|
this._draft = supersededDraft;
|
|
515
590
|
this._answerId = supersededId;
|
|
516
591
|
this._state = supersededDraft || supersededId ? "drafted" : "declined";
|
|
592
|
+
// 낡은 후보 세트가 새 실패 위에 남으면 화면이 거짓을 고른다 — 비운다 (0.8.0)
|
|
593
|
+
this._candidates = [];
|
|
594
|
+
this._chosenVariant = "primary";
|
|
595
|
+
this._setUiCandidates();
|
|
517
596
|
this._setUiDraft(supersededDraft);
|
|
518
597
|
this._setStatus(d.textOf(result.declinedReason || "unknown"));
|
|
519
598
|
this._persist();
|
|
@@ -554,18 +633,25 @@ export class InquirySession {
|
|
|
554
633
|
this._draft = "";
|
|
555
634
|
this._statusText = "";
|
|
556
635
|
this._state = "idle";
|
|
636
|
+
this._candidates = []; // 다안도 초안과 같은 수명 (0.8.0)
|
|
637
|
+
this._chosenVariant = "primary";
|
|
638
|
+
this._setUiCandidates();
|
|
557
639
|
this._clearPersist(); // 발송·폐기된 초안을 브라우저에 남기지 않는다
|
|
558
640
|
}
|
|
559
641
|
if (!this._d.client || !id) return;
|
|
560
642
|
send(id);
|
|
561
643
|
}
|
|
562
644
|
|
|
563
|
-
/** 발송 — 채택본이었다면 gold 쌍 후킹, 마지막 판단이라 기록을
|
|
645
|
+
/** 발송 — 채택본이었다면 gold 쌍 후킹, 마지막 판단이라 기록을 비운다.
|
|
646
|
+
* 다안(0.8.0): 본안이 아닌 후보를 골라 보냈으면 note 에 `variant:<id>` 가 남는다. */
|
|
564
647
|
answerSent(finalText, agent) {
|
|
565
|
-
|
|
648
|
+
const variant = this._chosenVariant;
|
|
649
|
+
this._emit((id) => this._d.client.sent(
|
|
650
|
+
id, finalText, agent, variant !== "primary" ? `variant:${variant}` : "",
|
|
651
|
+
), true);
|
|
566
652
|
}
|
|
567
653
|
|
|
568
|
-
/** 품질 점수 1~5 — 발송 전 여러 번 가능 (비소모) */
|
|
654
|
+
/** 품질 점수 1~5 — 발송 전 여러 번 가능 (비소모). 사유는 여기 안 실린다(아래 계약). */
|
|
569
655
|
scored(score, agent) {
|
|
570
656
|
this._emit((id) => this._d.client.scored(id, score, agent), false);
|
|
571
657
|
}
|
|
@@ -575,8 +661,8 @@ export class InquirySession {
|
|
|
575
661
|
this._emit((id) => this._d.client.edited(id, finalText, agent), false);
|
|
576
662
|
}
|
|
577
663
|
|
|
578
|
-
/** 폐기 + 이유 — 마지막 판단이라 기록을
|
|
579
|
-
discarded(agent, note) {
|
|
580
|
-
this._emit((id) => this._d.client.discarded(id, agent, note), true);
|
|
664
|
+
/** 폐기 + 이유 — 마지막 판단이라 기록을 비운다. reasonCode 는 서버 enum(0057). */
|
|
665
|
+
discarded(agent, note, reasonCode) {
|
|
666
|
+
this._emit((id) => this._d.client.discarded(id, agent, note, reasonCode), true);
|
|
581
667
|
}
|
|
582
668
|
}
|
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,26 @@ 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;
|
|
87
|
+
|
|
88
|
+
export declare const CAND_CLS: Record<string, string>;
|
|
89
|
+
/** 다안 피커 트리 (0.8.0) — 2안 미만이면 null. 노드의 index 는 choose(index) 배선용. */
|
|
90
|
+
export declare function candidatePickerTree(input: {
|
|
91
|
+
candidates: Array<{ variant: string; label: string; answer: string }>;
|
|
92
|
+
chosen?: string;
|
|
93
|
+
messages?: Record<string, string>;
|
|
94
|
+
}): object | 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,150 @@ 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
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
// ── 다안 피커 (0.8.0) — 후보 2~3안 중 하나를 초안으로 ──────────────────────
|
|
425
|
+
export const CAND_CLS = {
|
|
426
|
+
root: "fcx-cand",
|
|
427
|
+
item: "fcx-cand-item",
|
|
428
|
+
label: "fcx-cand-label",
|
|
429
|
+
excerpt: "fcx-cand-excerpt",
|
|
430
|
+
meta: "fcx-cand-meta",
|
|
431
|
+
use: "fcx-cand-use",
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const CAND_EXCERPT_CHARS = 160;
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 후보 목록의 프레임워크 중립 트리. 후보가 2안 미만이면 null — 피커는
|
|
438
|
+
* "고를 것이 있을 때"만 선다 (빈 상자 금지, aiSuggestTree 와 같은 규율).
|
|
439
|
+
* 노드에 `index` 가 있으면 어댑터가 choose(index) 로 배선한다.
|
|
440
|
+
*/
|
|
441
|
+
export function candidatePickerTree({ candidates, chosen, messages } = {}) {
|
|
442
|
+
const list = Array.isArray(candidates) ? candidates : [];
|
|
443
|
+
if (list.length < 2) return null;
|
|
444
|
+
const m = messages || {};
|
|
445
|
+
return {
|
|
446
|
+
tag: "div", cls: CAND_CLS.root,
|
|
447
|
+
children: list.map((c, index) => {
|
|
448
|
+
const isChosen = String(c.variant || "") === String(chosen || "primary");
|
|
449
|
+
return {
|
|
450
|
+
tag: "div",
|
|
451
|
+
cls: isChosen ? `${CAND_CLS.item} is-chosen` : CAND_CLS.item,
|
|
452
|
+
children: [
|
|
453
|
+
{ tag: "span", cls: CAND_CLS.label,
|
|
454
|
+
text: String(c.label || c.variant || "") },
|
|
455
|
+
{ tag: "div", cls: CAND_CLS.excerpt,
|
|
456
|
+
text: String(c.answer || "").slice(0, CAND_EXCERPT_CHARS) },
|
|
457
|
+
{ tag: "span", cls: CAND_CLS.meta,
|
|
458
|
+
text: isChosen ? (m.ui_candidate_chosen || "") : "" },
|
|
459
|
+
...(isChosen ? [] : [{
|
|
460
|
+
tag: "button", cls: CAND_CLS.use, type: "button", disabled: false,
|
|
461
|
+
text: m.ui_candidate_use || "", action: "choose", index,
|
|
462
|
+
}]),
|
|
463
|
+
],
|
|
464
|
+
};
|
|
465
|
+
}),
|
|
466
|
+
};
|
|
467
|
+
}
|