@fcg-labs/cx-agent-hook 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MIGRATION.md CHANGED
@@ -228,3 +228,17 @@ headless 2단계: ① 스타일 0 — styles.css 를 import 하지 않는다.
228
228
  - 발송: 본안이 아닌 후보를 골라 보냈으면 `sent` 피드백의 `note` 에
229
229
  `variant:<id>` 가 남는다 — 와이어 계약(허브 Go) 무변경.
230
230
  - React: `CandidatePicker` 신규. `AiSuggestPanel` 은 무변경.
231
+
232
+ # 0.8.0 → 0.8.1 마이그레이션
233
+
234
+ ## 요약 — 후보 피커 표시 품질. **깨지는 것 없음** (additive)
235
+
236
+ - 발췌: 전 후보가 같은 인사말로 시작해 카드가 전부 똑같아 보이던 것 →
237
+ **공통 접두를 건너뛰고 차이가 나는 문장 첫머리부터** 보여준다. 절단은
238
+ 어절 경계에서만, 말줄임표는 실제 잘림에만 (160→200자).
239
+ - 카드에 전문 노드(`full`, `fcx-cand-full`)와 글자수 표기(`ui_candidate_length`)
240
+ 추가. React `CandidatePicker` 는 카드 클릭=전문 펼침(컴포넌트 로컬 상태,
241
+ [사용] 버튼과 분리). 트리를 그대로 걷는 구식 어댑터는 `full` 이 children
242
+ 밖이라 영향 없음.
243
+ - ko 카피: `ui_candidate_use` 「이 안 쓰기」 → **「이 안 사용」** ("안 쓰기(不用)"
244
+ 오독 교정, 2026-08-28 운영 워크스루). 다른 로케일 무변경.
package/locales.js CHANGED
@@ -32,6 +32,7 @@ const en = {
32
32
  ui_candidates: "AI draft options",
33
33
  ui_candidate_use: "Use this draft",
34
34
  ui_candidate_chosen: "Selected",
35
+ ui_candidate_length: "{n} chars",
35
36
  ui_feedback_ask: "Was this draft helpful?",
36
37
  ui_feedback_up: "Helpful",
37
38
  ui_feedback_down: "Not helpful",
@@ -98,8 +99,10 @@ const ko = {
98
99
  ui_requesting: "제안 생성 중...",
99
100
  ui_adopt: "에디터에 넣기",
100
101
  ui_candidates: "AI 답변 후보",
101
- ui_candidate_use: "이 안 쓰기",
102
+ // "이 안 쓰기"는 "안 쓰기(不用)"로 오독됐다 (2026-08-28 운영 워크스루)
103
+ ui_candidate_use: "이 안 사용",
102
104
  ui_candidate_chosen: "선택됨",
105
+ ui_candidate_length: "{n}자",
103
106
  ui_feedback_ask: "이 초안, 쓸 만했나요?",
104
107
  ui_feedback_up: "좋아요",
105
108
  ui_feedback_down: "싫어요",
@@ -163,6 +166,7 @@ const ja = {
163
166
  ui_candidates: "AI回答の候補",
164
167
  ui_candidate_use: "この案を使う",
165
168
  ui_candidate_chosen: "選択中",
169
+ ui_candidate_length: "{n}字",
166
170
  ui_feedback_ask: "この下書きは役に立ちましたか?",
167
171
  ui_feedback_up: "良い",
168
172
  ui_feedback_down: "悪い",
@@ -225,6 +229,7 @@ const zhTW = {
225
229
  ui_candidates: "AI 回覆候選",
226
230
  ui_candidate_use: "使用此草稿",
227
231
  ui_candidate_chosen: "已選擇",
232
+ ui_candidate_length: "{n} 字",
228
233
  ui_adopt: "插入編輯器",
229
234
  ui_feedback_ask: "這份草稿有幫助嗎?",
230
235
  ui_feedback_up: "有幫助",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcg-labs/cx-agent-hook",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/react.js CHANGED
@@ -339,22 +339,35 @@ export function AiFeedbackBar({ session, agent, actorClaimed, className }) {
339
339
  // messages={cxMessages} onChoose={(i) => session.choose(i)} />
340
340
  // ```
341
341
  // 표시 판단은 view.candidatePickerTree 가 소유한다 — 2안 미만이면 아무것도
342
- // 그리지 않는다. 선택은 세션(choose)이 정본이라 컴포넌트는 상태가 없다.
342
+ // 그리지 않는다. 선택은 세션(choose)이 정본이라 세션 상태는 없고, 전문
343
+ // 펼침(0.8.1)만 컴포넌트 로컬이다 — 카드 클릭=전문 토글, [사용] 버튼과 분리.
343
344
 
344
345
  export function CandidatePicker({ candidates, chosen, messages, onChoose, className }) {
346
+ const [expanded, setExpanded] = useState(() => new Set());
345
347
  const tree = candidatePickerTree({ candidates, chosen, messages });
346
348
  if (!tree) return null;
349
+ const toggle = (index) => setExpanded((prev) => {
350
+ const next = new Set(prev);
351
+ if (next.has(index)) next.delete(index); else next.add(index);
352
+ return next;
353
+ });
347
354
  const render = (node, key) => {
348
355
  const props = { key, className: node.cls };
349
356
  if (node.tag === "button") {
350
357
  props.type = node.type || "button";
351
358
  if (node.action === "choose" && onChoose) {
352
- props.onClick = () => onChoose(node.index);
359
+ props.onClick = (event) => { event.stopPropagation(); onChoose(node.index); };
353
360
  }
354
361
  }
355
- const children = node.children
362
+ if (node.action === "toggle") {
363
+ props.onClick = () => toggle(node.index);
364
+ }
365
+ const inner = node.children
356
366
  ? node.children.map((c, i) => render(c, i))
357
367
  : node.text;
368
+ const children = node.action === "toggle" && node.full && expanded.has(node.index)
369
+ ? [...inner, h("div", { key: "full", className: node.full.cls }, node.full.text)]
370
+ : inner;
358
371
  return h(node.tag, props, children);
359
372
  };
360
373
  return h(
package/view.js CHANGED
@@ -429,9 +429,43 @@ export const CAND_CLS = {
429
429
  excerpt: "fcx-cand-excerpt",
430
430
  meta: "fcx-cand-meta",
431
431
  use: "fcx-cand-use",
432
+ full: "fcx-cand-full",
432
433
  };
433
434
 
434
- const CAND_EXCERPT_CHARS = 160;
435
+ const CAND_EXCERPT_CHARS = 200;
436
+ const CAND_MIN_SHARED_PREFIX = 24;
437
+
438
+ /**
439
+ * 전 후보 공통 접두(공통 인사·도입)의 끝 — 발췌는 여기서부터.
440
+ * 후보들이 같은 인사말로 시작하면 머리 발췌는 전부 같아 보인다(변별 불가).
441
+ * 경계는 공통부 안의 마지막 문장·줄 시작으로 후퇴해, 차이가 나는 문장을
442
+ * 첫머리부터 온전히 보여준다. 공통부가 짧으면 0 (머리부터).
443
+ */
444
+ function candidateExcerptStart(answers) {
445
+ if (answers.length < 2) return 0;
446
+ const first = answers[0];
447
+ let n = 0;
448
+ while (n < first.length && answers.every((t) => n < t.length && t[n] === first[n])) n += 1;
449
+ if (n < CAND_MIN_SHARED_PREFIX) return 0;
450
+ const head = first.slice(0, n);
451
+ let boundary = Math.max(
452
+ head.lastIndexOf("\n"), head.lastIndexOf(". "),
453
+ head.lastIndexOf("! "), head.lastIndexOf("? "),
454
+ );
455
+ if (boundary < 0) boundary = head.lastIndexOf(" "); // 문장 부호 없는 공통부 → 어절 경계
456
+ let start = boundary >= 0 ? boundary + 1 : n;
457
+ while (start < first.length && /\s/.test(first[start])) start += 1;
458
+ return start;
459
+ }
460
+
461
+ /** 어절 경계 절단 — 발췌가 낱말 중간에서 끊기지 않고, 말줄임표는 실제 잘림에만. */
462
+ function clipCandidateExcerpt(text, limit) {
463
+ const t = String(text || "");
464
+ if (t.length <= limit) return t;
465
+ let end = Math.max(t.lastIndexOf(" ", limit), t.lastIndexOf("\n", limit));
466
+ if (end < Math.floor(limit * 0.6)) end = limit; // 공백 없는 긴 덩어리는 그대로
467
+ return `${t.slice(0, end).replace(/\s+$/, "")}…`;
468
+ }
435
469
 
436
470
  /**
437
471
  * 후보 목록의 프레임워크 중립 트리. 후보가 2안 미만이면 null — 피커는
@@ -442,20 +476,32 @@ export function candidatePickerTree({ candidates, chosen, messages } = {}) {
442
476
  const list = Array.isArray(candidates) ? candidates : [];
443
477
  if (list.length < 2) return null;
444
478
  const m = messages || {};
479
+ const start = candidateExcerptStart(list.map((c) => String(c.answer || "")));
445
480
  return {
446
481
  tag: "div", cls: CAND_CLS.root,
447
482
  children: list.map((c, index) => {
448
483
  const isChosen = String(c.variant || "") === String(chosen || "primary");
484
+ const answer = String(c.answer || "");
485
+ const sliced = answer.slice(start) || answer; // 공통부뿐인 후보(방어) → 머리
486
+ const excerpt = (start > 0 && sliced !== answer ? "…" : "")
487
+ + clipCandidateExcerpt(sliced, CAND_EXCERPT_CHARS);
488
+ const lengthText = String(m.ui_candidate_length || "{n}")
489
+ .replace("{n}", String(answer.length));
449
490
  return {
450
491
  tag: "div",
451
492
  cls: isChosen ? `${CAND_CLS.item} is-chosen` : CAND_CLS.item,
493
+ // 어댑터 계약: action=toggle 인 항목은 클릭 시 full(전문)을 펼친다.
494
+ // full 은 children 밖 — 트리를 그대로 걷는 구식 어댑터는 못 보므로
495
+ // 펼침을 모르는 소비처에서도 발췌만 그려져 회귀가 없다.
496
+ action: "toggle", index,
497
+ full: { tag: "div", cls: CAND_CLS.full, text: answer },
452
498
  children: [
453
499
  { tag: "span", cls: CAND_CLS.label,
454
500
  text: String(c.label || c.variant || "") },
455
- { tag: "div", cls: CAND_CLS.excerpt,
456
- text: String(c.answer || "").slice(0, CAND_EXCERPT_CHARS) },
501
+ { tag: "div", cls: CAND_CLS.excerpt, text: excerpt },
457
502
  { tag: "span", cls: CAND_CLS.meta,
458
- text: isChosen ? (m.ui_candidate_chosen || "") : "" },
503
+ text: isChosen && m.ui_candidate_chosen
504
+ ? `${lengthText} · ${m.ui_candidate_chosen}` : lengthText },
459
505
  ...(isChosen ? [] : [{
460
506
  tag: "button", cls: CAND_CLS.use, type: "button", disabled: false,
461
507
  text: m.ui_candidate_use || "", action: "choose", index,