@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 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
- sent(answerId, finalText, agent) {
447
- return this.sendFeedback({ answerId, action: "sent", finalText, agent });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcg-labs/cx-agent-hook",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
5
5
  "type": "module",
6
6
  "main": "index.js",
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
- export function AiSuggestPanel({ hook, inquiry, context, onAdopt, className }) {
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
- // 지운다 지우면 다음 문의를 발송할 엉뚱한 answer_id 후킹된다.
97
+ // 다른 문의로 옮기면 앞 문의의 제안이 남아 있으면 안 된다.
98
+ // 채택 기록은 **단일 훅(hook) 계약에서만** 같이 지운다 하나가 모든 문의를
99
+ // 겸하므로 안 지우면 다음 문의 발송이 엉뚱한 answer_id 로 후킹된다.
100
+ // 세션 계약에서는 지우지 않는다: 세션이 문의별로 따로라 섞일 일이 없고,
101
+ // 여기서 지우면 옮겨 간 문의의 세션이 가진 id 까지 죽는다(0.7.1).
57
102
  useEffect(() => {
58
103
  setState("idle");
59
104
  setResult(null);
60
- hook.clearAdopted();
61
- }, [inquiry, hook]);
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 hook.requestAnswer(inquiry, contextRef.current));
117
+ setResult(await api.requestAnswer(inquiry, contextRef.current));
72
118
  setState("done");
73
- }, [hook, inquiry, state]);
119
+ }, [api, inquiry, state]);
74
120
 
75
121
  const adopt = useCallback(() => {
76
122
  if (!result || !result.answered) return;
77
- hook.noteAdopted(result.answerId);
123
+ api.noteAdopted(result.answerId);
78
124
  if (onAdopt) onAdopt(result.answer);
79
- }, [hook, onAdopt, result]);
125
+ }, [api, onAdopt, result]);
80
126
 
81
- if (!hook || !hook.enabled) return null;
127
+ if (!api || !api.enabled) return null;
82
128
 
83
129
  const tree = aiSuggestTree({
84
- state, result, declineText: hook.declineText, messages: hook.messages,
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, { request, adopt }, i)),
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) => { accumulated += text; queueDraft(); },
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
- if (phase === "generating") this._setStatus(d.messages.ui_requesting);
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
- this._emit((id) => this._d.client.sent(id, finalText, agent), true);
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) => e.title || e.unit_key)
35
- .filter(Boolean),
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
- { tag: "button", cls: CLS.button, type: "button",
82
- disabled: v.buttonDisabled, text: v.buttonLabel, action: "request" },
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
- ...(v.body.evidence.length > 0
96
- ? [{ tag: "div", cls: CLS.evidence,
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
+ }