@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 +28 -0
- package/agent.d.ts +9 -0
- package/client.d.ts +3 -0
- package/client.js +13 -7
- package/index.d.ts +6 -0
- package/locales.js +17 -0
- package/package.json +1 -1
- package/react.d.ts +9 -0
- package/react.js +48 -1
- package/session.js +46 -2
- package/view.d.ts +8 -0
- package/view.js +92 -0
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
|
-
|
|
450
|
-
|
|
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
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
|
-
|
|
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
|
+
}
|