@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 +14 -0
- package/locales.js +6 -1
- package/package.json +1 -1
- package/react.js +16 -3
- package/view.js +50 -4
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
|
-
|
|
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
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
|
-
|
|
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 =
|
|
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
|
|
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,
|