@fcg-labs/cx-agent-hook 0.7.1 → 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
@@ -214,3 +214,31 @@ headless 2단계: ① 스타일 0 — styles.css 를 import 하지 않는다.
214
214
  이후 허브 발급분은 문자열이다. 불투명 취급하면 코드 변경은 없다.
215
215
  - element 어댑터의 내부 DOM 구조는 동일하나, 어댑터 3종이 `aiSuggestTree`
216
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` 은 무변경.
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/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
  };
@@ -445,9 +447,13 @@ export class CxAgentClient {
445
447
  return { ok: false, feedbackId: null, error: code };
446
448
  }
447
449
 
448
- /** 발송 후킹 — CS팀이 실제로 내보낸 최종 문구 (gold 학습 쌍의 원천) */
449
- sent(answerId, finalText, agent) {
450
- 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
+ });
451
457
  }
452
458
 
453
459
  /** 점수 후킹 (1~5) */
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,6 +29,10 @@ 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_candidate_length: "{n} chars",
32
36
  ui_feedback_ask: "Was this draft helpful?",
33
37
  ui_feedback_up: "Helpful",
34
38
  ui_feedback_down: "Not helpful",
@@ -94,6 +98,11 @@ const ko = {
94
98
  ui_request: "AI 답변 제안",
95
99
  ui_requesting: "제안 생성 중...",
96
100
  ui_adopt: "에디터에 넣기",
101
+ ui_candidates: "AI 답변 후보",
102
+ // "이 안 쓰기"는 "안 쓰기(不用)"로 오독됐다 (2026-08-28 운영 워크스루)
103
+ ui_candidate_use: "이 안 사용",
104
+ ui_candidate_chosen: "선택됨",
105
+ ui_candidate_length: "{n}자",
97
106
  ui_feedback_ask: "이 초안, 쓸 만했나요?",
98
107
  ui_feedback_up: "좋아요",
99
108
  ui_feedback_down: "싫어요",
@@ -154,6 +163,10 @@ const ja = {
154
163
  ui_request: "AI 返信案",
155
164
  ui_requesting: "生成中...",
156
165
  ui_adopt: "エディタに挿入",
166
+ ui_candidates: "AI回答の候補",
167
+ ui_candidate_use: "この案を使う",
168
+ ui_candidate_chosen: "選択中",
169
+ ui_candidate_length: "{n}字",
157
170
  ui_feedback_ask: "この下書きは役に立ちましたか?",
158
171
  ui_feedback_up: "良い",
159
172
  ui_feedback_down: "悪い",
@@ -213,6 +226,10 @@ const ja = {
213
226
  const zhTW = {
214
227
  ui_request: "AI 回覆建議",
215
228
  ui_requesting: "產生中...",
229
+ ui_candidates: "AI 回覆候選",
230
+ ui_candidate_use: "使用此草稿",
231
+ ui_candidate_chosen: "已選擇",
232
+ ui_candidate_length: "{n} 字",
216
233
  ui_adopt: "插入編輯器",
217
234
  ui_feedback_ask: "這份草稿有幫助嗎?",
218
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.7.1",
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.d.ts CHANGED
@@ -41,3 +41,12 @@ export interface AiFeedbackBarProps {
41
41
  export declare function AiFeedbackBar(props: AiFeedbackBarProps): ReactElement | null;
42
42
 
43
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, feedbackBarTree } from "./view.js";
22
+ import { actionOffersTree, aiSuggestTree, aiSuggestView, candidatePickerTree, CLS, feedbackBarTree } from "./view.js";
23
23
 
24
24
  // 표시 판단은 프레임워크 중립 코어가 갖는다 — Vue·웹 컴포넌트와 같은 것을 쓴다.
25
25
  export { aiSuggestView };
@@ -330,3 +330,50 @@ export function AiFeedbackBar({ session, agent, actorClaimed, className }) {
330
330
  onClick: (event) => event.stopPropagation() },
331
331
  tree.children.map((n, i) => render(n, i)));
332
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
+ // 펼침(0.8.1)만 컴포넌트 로컬이다 — 카드 클릭=전문 토글, [사용] 버튼과 분리.
344
+
345
+ export function CandidatePicker({ candidates, chosen, messages, onChoose, className }) {
346
+ const [expanded, setExpanded] = useState(() => new Set());
347
+ const tree = candidatePickerTree({ candidates, chosen, messages });
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
+ });
354
+ const render = (node, key) => {
355
+ const props = { key, className: node.cls };
356
+ if (node.tag === "button") {
357
+ props.type = node.type || "button";
358
+ if (node.action === "choose" && onChoose) {
359
+ props.onClick = (event) => { event.stopPropagation(); onChoose(node.index); };
360
+ }
361
+ }
362
+ if (node.action === "toggle") {
363
+ props.onClick = () => toggle(node.index);
364
+ }
365
+ const inner = node.children
366
+ ? node.children.map((c, i) => render(c, i))
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;
371
+ return h(node.tag, props, children);
372
+ };
373
+ return h(
374
+ "div",
375
+ { className: className ? `${tree.cls} ${className}` : tree.cls,
376
+ onClick: (event) => event.stopPropagation() }, // 문의 행 선택 토글 방지 (패널 선례)
377
+ tree.children.map((c, i) => render(c, i)),
378
+ );
379
+ }
package/session.js CHANGED
@@ -166,6 +166,9 @@ export class InquirySession {
166
166
  this._locks = {}; // action_key\x00target_key → 잠금 조회 결과 (세션 캐시)
167
167
  this._verify = {}; // offer_id → 대상 확정(더블체크) 결과 (T2.7, 화면 표시용 캐시)
168
168
 
169
+ this._candidates = []; // 다안 (0.8.0) — 데코레이트 완료본, [0]=본안
170
+ this._chosenVariant = "primary";
171
+
169
172
  // 영속 복원 — TTL 만료분은 store 가 이미 걸렀다
170
173
  if (this._id && deps.store) {
171
174
  const rec = deps.store.load(this._id);
@@ -174,6 +177,8 @@ export class InquirySession {
174
177
  this._answerId = rec.answerId ?? null;
175
178
  this._state = rec.state === "declined" ? "declined"
176
179
  : (this._draft || this._answerId ? "drafted" : "idle");
180
+ this._candidates = Array.isArray(rec.candidates) ? rec.candidates : [];
181
+ this._chosenVariant = rec.chosenVariant || "primary";
177
182
  }
178
183
  }
179
184
  }
@@ -358,6 +363,26 @@ export class InquirySession {
358
363
  get state() { return this._state; }
359
364
  get draft() { return this._draft; }
360
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
+ }
361
386
 
362
387
  /** UI sink 연결 — 초안을 자동 주입하지 않는다 (복원은 restore() 명시 호출).
363
388
  * @param {{getDraft?:()=>string, setDraft:(t:string)=>void,
@@ -378,6 +403,7 @@ export class InquirySession {
378
403
  if (this._ui) {
379
404
  this._ui.setDraft(this._draft);
380
405
  if (this._ui.setStatus) this._ui.setStatus(this._statusText);
406
+ this._setUiCandidates();
381
407
  }
382
408
  return { draft: this._draft, answerId: this._answerId, state: this._state };
383
409
  }
@@ -393,6 +419,7 @@ export class InquirySession {
393
419
  if (this._id && this._d.store) {
394
420
  this._d.store.save(this._id, {
395
421
  draft: this._draft, answerId: this._answerId, state: this._state,
422
+ candidates: this._candidates, chosenVariant: this._chosenVariant,
396
423
  });
397
424
  }
398
425
  }
@@ -547,6 +574,12 @@ export class InquirySession {
547
574
  d.client.discarded(supersededId, "", "redraft");
548
575
  }
549
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();
550
583
  this._state = "drafted";
551
584
  this._setStatus("");
552
585
  this._persist();
@@ -556,6 +589,10 @@ export class InquirySession {
556
589
  this._draft = supersededDraft;
557
590
  this._answerId = supersededId;
558
591
  this._state = supersededDraft || supersededId ? "drafted" : "declined";
592
+ // 낡은 후보 세트가 새 실패 위에 남으면 화면이 거짓을 고른다 — 비운다 (0.8.0)
593
+ this._candidates = [];
594
+ this._chosenVariant = "primary";
595
+ this._setUiCandidates();
559
596
  this._setUiDraft(supersededDraft);
560
597
  this._setStatus(d.textOf(result.declinedReason || "unknown"));
561
598
  this._persist();
@@ -596,15 +633,22 @@ export class InquirySession {
596
633
  this._draft = "";
597
634
  this._statusText = "";
598
635
  this._state = "idle";
636
+ this._candidates = []; // 다안도 초안과 같은 수명 (0.8.0)
637
+ this._chosenVariant = "primary";
638
+ this._setUiCandidates();
599
639
  this._clearPersist(); // 발송·폐기된 초안을 브라우저에 남기지 않는다
600
640
  }
601
641
  if (!this._d.client || !id) return;
602
642
  send(id);
603
643
  }
604
644
 
605
- /** 발송 — 채택본이었다면 gold 쌍 후킹, 마지막 판단이라 기록을 비운다 */
645
+ /** 발송 — 채택본이었다면 gold 쌍 후킹, 마지막 판단이라 기록을 비운다.
646
+ * 다안(0.8.0): 본안이 아닌 후보를 골라 보냈으면 note 에 `variant:<id>` 가 남는다. */
606
647
  answerSent(finalText, agent) {
607
- 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);
608
652
  }
609
653
 
610
654
  /** 품질 점수 1~5 — 발송 전 여러 번 가능 (비소모). 사유는 여기 안 실린다(아래 계약). */
package/view.d.ts CHANGED
@@ -84,3 +84,11 @@ export declare function feedbackBarView(input: {
84
84
  } | null;
85
85
  /** action: "up" | "down" — 어댑터가 자기 이벤트로 배선한다 */
86
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
@@ -419,3 +419,95 @@ export function feedbackBarTree(input) {
419
419
  ],
420
420
  };
421
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
+ full: "fcx-cand-full",
433
+ };
434
+
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
+ }
469
+
470
+ /**
471
+ * 후보 목록의 프레임워크 중립 트리. 후보가 2안 미만이면 null — 피커는
472
+ * "고를 것이 있을 때"만 선다 (빈 상자 금지, aiSuggestTree 와 같은 규율).
473
+ * 노드에 `index` 가 있으면 어댑터가 choose(index) 로 배선한다.
474
+ */
475
+ export function candidatePickerTree({ candidates, chosen, messages } = {}) {
476
+ const list = Array.isArray(candidates) ? candidates : [];
477
+ if (list.length < 2) return null;
478
+ const m = messages || {};
479
+ const start = candidateExcerptStart(list.map((c) => String(c.answer || "")));
480
+ return {
481
+ tag: "div", cls: CAND_CLS.root,
482
+ children: list.map((c, index) => {
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));
490
+ return {
491
+ tag: "div",
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 },
498
+ children: [
499
+ { tag: "span", cls: CAND_CLS.label,
500
+ text: String(c.label || c.variant || "") },
501
+ { tag: "div", cls: CAND_CLS.excerpt, text: excerpt },
502
+ { tag: "span", cls: CAND_CLS.meta,
503
+ text: isChosen && m.ui_candidate_chosen
504
+ ? `${lengthText} · ${m.ui_candidate_chosen}` : lengthText },
505
+ ...(isChosen ? [] : [{
506
+ tag: "button", cls: CAND_CLS.use, type: "button", disabled: false,
507
+ text: m.ui_candidate_use || "", action: "choose", index,
508
+ }]),
509
+ ],
510
+ };
511
+ }),
512
+ };
513
+ }