@fcg-labs/cx-agent-hook 0.3.0 → 0.5.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/AGENTS.md +14 -0
- package/MIGRATION.md +82 -0
- package/README.md +67 -0
- package/actions.js +168 -0
- package/agent.d.ts +128 -1
- package/agent.js +56 -3
- package/angular.d.ts +2 -2
- package/angular.js +1 -1
- package/astro.d.ts +2 -2
- package/astro.js +1 -0
- package/bin/cx-agent-hook.js +78 -0
- package/client.js +166 -0
- package/element.d.ts +12 -1
- package/element.js +124 -1
- package/locales.js +68 -0
- package/next.d.ts +2 -2
- package/next.js +1 -1
- package/package.json +11 -4
- package/react.d.ts +14 -0
- package/react.js +80 -1
- package/session.js +192 -0
- package/skills/cx-agent-hook-setup/SKILL.md +195 -0
- package/skills/cx-agent-hook-setup/reference.md +92 -0
- package/styles.css +65 -1
- package/view.d.ts +29 -2
- package/view.js +129 -0
- package/vue.d.ts +10 -0
- package/vue.js +80 -2
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cx-agent-hook CLI — 에이전트 스킬 설치 (의존성 0).
|
|
4
|
+
*
|
|
5
|
+
* 개발자가 자기 코딩 에이전트에게 "cx-agent-hook 셋업 도와줘" 라고만 해도 되게, 패키지에
|
|
6
|
+
* 실린 스킬(skills/cx-agent-hook-setup/SKILL.md + reference.md)을 프로젝트의 에이전트
|
|
7
|
+
* 디렉터리에 복사한다. 네트워크 0 — 설치된 패키지 안의 파일만 쓴다.
|
|
8
|
+
*
|
|
9
|
+
* npx cx-agent-hook skills install # → .claude/skills/cx-agent-hook-setup/ (Claude Code)
|
|
10
|
+
* npx cx-agent-hook skills install --cursor # + .cursor/rules/cx-agent-hook-setup.mdc (Cursor 규칙 → 스킬 참조)
|
|
11
|
+
* npx cx-agent-hook skills install --agents-md# + AGENTS.md 에 한 줄 추가 (Codex 등 AGENTS.md 계열)
|
|
12
|
+
* npx cx-agent-hook skills install --all # 위 전부
|
|
13
|
+
* npx cx-agent-hook skills install --to DIR # 임의 디렉터리 (예: .agents/skills)
|
|
14
|
+
* npx cx-agent-hook skills path # SKILL.md 절대 경로 (에이전트에게 "이 파일 읽어" 라고 줄 때)
|
|
15
|
+
*/
|
|
16
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const pkgRoot = resolve(here, "..");
|
|
22
|
+
const SKILL = "cx-agent-hook-setup";
|
|
23
|
+
const src = join(pkgRoot, "skills", SKILL);
|
|
24
|
+
const [cmd, sub, ...rest] = process.argv.slice(2);
|
|
25
|
+
|
|
26
|
+
const flag = (name) => rest.includes(name);
|
|
27
|
+
const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
|
|
28
|
+
const cwd = process.cwd();
|
|
29
|
+
const log = (s) => process.stdout.write(s + "\n");
|
|
30
|
+
|
|
31
|
+
function usage(code = 0) {
|
|
32
|
+
log(`cx-agent-hook skills <install|path> [--to DIR] [--cursor] [--agents-md] [--all]
|
|
33
|
+
install 스킬을 .claude/skills/${SKILL}/ 에 복사 (Claude Code). --cursor / --agents-md / --all 로 다른 에이전트도.
|
|
34
|
+
path SKILL.md 절대 경로 출력 — 에이전트에게 "이 파일을 읽고 따라줘" 라고 줄 때.
|
|
35
|
+
그 뒤 에이전트에게: "cx-agent-hook 셋업 도와줘" (adminApi·처리 카드까지가 셋업이다)`);
|
|
36
|
+
process.exit(code);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (cmd !== "skills" || !sub) usage(cmd ? 1 : 0);
|
|
40
|
+
if (!existsSync(join(src, "SKILL.md"))) { log(`스킬 파일이 없습니다: ${src}`); process.exit(1); }
|
|
41
|
+
|
|
42
|
+
if (sub === "path") { log(join(src, "SKILL.md")); process.exit(0); }
|
|
43
|
+
if (sub !== "install") usage(1);
|
|
44
|
+
|
|
45
|
+
const done = [];
|
|
46
|
+
const to = opt("--to");
|
|
47
|
+
const targets = to ? [resolve(cwd, to, SKILL)] : [join(cwd, ".claude", "skills", SKILL)];
|
|
48
|
+
if (flag("--all") && !to) { /* Claude Code 는 기본 대상 */ }
|
|
49
|
+
for (const dir of targets) {
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
cpSync(src, dir, { recursive: true });
|
|
52
|
+
done.push(`스킬 복사 → ${dir}`);
|
|
53
|
+
}
|
|
54
|
+
const skillRel = to ? join(to, SKILL, "SKILL.md") : join(".claude", "skills", SKILL, "SKILL.md");
|
|
55
|
+
|
|
56
|
+
if (flag("--cursor") || flag("--all")) {
|
|
57
|
+
const rulesDir = join(cwd, ".cursor", "rules");
|
|
58
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
59
|
+
const mdc = join(rulesDir, `${SKILL}.mdc`);
|
|
60
|
+
writeFileSync(mdc, `---
|
|
61
|
+
description: cx-agent-hook (@fcg-labs/cx-agent-hook) 셋업·배선·업그레이드 — AI 답변 초안 패널 + 처리 카드(adminApi)
|
|
62
|
+
globs:
|
|
63
|
+
alwaysApply: false
|
|
64
|
+
---
|
|
65
|
+
cx-agent-hook 관련 작업(셋업·연동·처리 카드·adminApi·업그레이드)은 먼저 \`${skillRel}\` 을 읽고
|
|
66
|
+
그 절차(실측 → 결정 규칙 → 6단계 → 완료 체크리스트)를 그대로 따른다. reference.md 에 없는 API 이름을 쓰지 않는다.
|
|
67
|
+
`);
|
|
68
|
+
done.push(`Cursor 규칙 → ${mdc}`);
|
|
69
|
+
}
|
|
70
|
+
if (flag("--agents-md") || flag("--all")) {
|
|
71
|
+
const agentsMd = join(cwd, "AGENTS.md");
|
|
72
|
+
const line = `\n## cx-agent-hook\ncx-agent-hook(@fcg-labs/cx-agent-hook) 셋업·연동·처리 카드(adminApi)·업그레이드 작업은 먼저 \`${skillRel}\` 을 읽고 그 절차와 완료 체크리스트를 따른다.\n`;
|
|
73
|
+
const cur = existsSync(agentsMd) ? readFileSync(agentsMd, "utf8") : "";
|
|
74
|
+
if (cur.includes("cx-agent-hook-setup")) done.push(`AGENTS.md 는 이미 스킬을 가리킴 (변경 없음)`);
|
|
75
|
+
else { appendFileSync(agentsMd, (cur && !cur.endsWith("\n") ? "\n" : "") + line); done.push(`AGENTS.md 에 안내 한 줄 추가 → ${agentsMd}`); }
|
|
76
|
+
}
|
|
77
|
+
for (const d of done) log(d);
|
|
78
|
+
log(`\n다음: 에이전트에게 "cx-agent-hook 셋업 도와줘" — 답변 패널과 처리 카드(adminApi)까지가 셋업입니다.`);
|
package/client.js
CHANGED
|
@@ -37,6 +37,10 @@ const PATHS = {
|
|
|
37
37
|
ingress: (d) => `/v1/domains/${d}/ingress`,
|
|
38
38
|
// 조사 접점 (E-8) — 트리거는 큐 적재만, 상태는 경량 메타만 (허브 계약)
|
|
39
39
|
investigation: (d) => `/v1/domains/${d}/investigations`,
|
|
40
|
+
// 처리 액션 (actions/1 프로파일 B) — 선점·결과 보고·상태·잠금·후보 발행.
|
|
41
|
+
// 정본: docs/contracts/action-contract-v1.md
|
|
42
|
+
actions: (d) => `/v1/domains/${d}/actions`,
|
|
43
|
+
actionCandidates: (d) => `/v1/domains/${d}/action-candidates`,
|
|
40
44
|
},
|
|
41
45
|
// platform 은 인그레스 API 미제공 (공장 큐레이션 파이프라인이 담당)
|
|
42
46
|
};
|
|
@@ -591,6 +595,168 @@ export class CxAgentClient {
|
|
|
591
595
|
clearTimeout(timer);
|
|
592
596
|
}
|
|
593
597
|
}
|
|
598
|
+
|
|
599
|
+
// ── 처리 액션 (actions/1 프로파일 B — 브라우저 실행기) ────────────────────
|
|
600
|
+
//
|
|
601
|
+
// 흐름: ① requestAction 으로 허브에 선점(received) → ② 호출자가 CMS 자기 API 를
|
|
602
|
+
// 부른다 → ③ reportActionResult 로 succeeded/failed/unknown 보고. 허브는 어떤
|
|
603
|
+
// 고객사 서버도 부르지 않는다. **재시도 0.** 선점은 부작용이 없어 미도달(404)
|
|
604
|
+
// 확정 시 1회 재전송이 안전하지만(session 규칙), CMS 호출·보고에는 재시도가 없다.
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* 선점. request_id 는 호출자(세션)가 발급·영속한 뒤 넘긴다 — 멱등 키.
|
|
608
|
+
* @returns {Promise<{ok:boolean, state:string, requestId:string, reasonCode:string,
|
|
609
|
+
* message:string, targetKey:string, deduplicated:boolean, error:string|null, httpStatus:number}>}
|
|
610
|
+
*/
|
|
611
|
+
async requestAction({ requestId, offer, actorClaimed, inquiryRef } = {}) {
|
|
612
|
+
if (this.api !== "hub" || !requestId || !offer || !offer.offer_id) {
|
|
613
|
+
return { ok: false, state: "", requestId: requestId || "", reasonCode: "", message: "",
|
|
614
|
+
targetKey: "", deduplicated: false, error: "unsupported", httpStatus: 0 };
|
|
615
|
+
}
|
|
616
|
+
const body = {
|
|
617
|
+
request_id: String(requestId), offer_id: String(offer.offer_id),
|
|
618
|
+
event_id: String(offer.event_id || ""), action_key: String(offer.action_key || ""),
|
|
619
|
+
params_hash: String(offer.params_hash || ""), params: offer.params_bound || {},
|
|
620
|
+
target_key: String(offer.target_key || ""), actor_claimed: String(actorClaimed || ""),
|
|
621
|
+
inquiry_ref: inquiryRef != null ? String(inquiryRef) : "",
|
|
622
|
+
confirmed_at: new Date().toISOString(),
|
|
623
|
+
};
|
|
624
|
+
try {
|
|
625
|
+
const { status, data } = await this._post(PATHS.hub.actions(this.domain), body);
|
|
626
|
+
const view = (d) => ({
|
|
627
|
+
state: d.state || "", requestId: d.request_id || String(requestId),
|
|
628
|
+
reasonCode: d.reason_code || "", message: d.message || "",
|
|
629
|
+
targetKey: d.target_key || "", deduplicated: Boolean(d.deduplicated),
|
|
630
|
+
});
|
|
631
|
+
if (status === 200 || status === 202) {
|
|
632
|
+
return { ok: true, ...view(data), error: null, httpStatus: status };
|
|
633
|
+
}
|
|
634
|
+
// 409 offer_locked / request_conflict, 422 계약 위반 — 그대로 전달
|
|
635
|
+
this.onError(new Error(`HTTP ${status}`), { op: "requestAction", code: data.error || "http_error" });
|
|
636
|
+
return { ok: false, ...view(data), error: data.error || `http_${status}`, httpStatus: status };
|
|
637
|
+
} catch (err) {
|
|
638
|
+
this.onError(err, { op: "requestAction", code: "network_error" });
|
|
639
|
+
return { ok: false, state: "", requestId: String(requestId), reasonCode: "", message: "",
|
|
640
|
+
targetKey: "", deduplicated: false, error: "network_error", httpStatus: 0 };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* 결과 보고 — CMS 호출이 끝난 뒤 1회. 같은 종단 재보고는 허브가 멱등(200 deduplicated),
|
|
646
|
+
* 다른 종단은 409 state_conflict, 선점 없는 request 는 404.
|
|
647
|
+
* @param {string} requestId
|
|
648
|
+
* @param {{state:"succeeded"|"failed"|"unknown", reasonCode?:string, message?:string,
|
|
649
|
+
* httpStatus?:number, latencyMs?:number, result?:object}} r
|
|
650
|
+
*/
|
|
651
|
+
async reportActionResult(requestId, { state, reasonCode, message, httpStatus, latencyMs, result } = {}) {
|
|
652
|
+
if (this.api !== "hub" || !requestId) return { ok: false, state: "", error: "unsupported", httpStatus: 0 };
|
|
653
|
+
const body = {
|
|
654
|
+
state: String(state || ""), reason_code: String(reasonCode || ""), message: String(message || "").slice(0, 200),
|
|
655
|
+
http_status: Number(httpStatus) || 0, latency_ms: Number(latencyMs) || 0, result: result || null,
|
|
656
|
+
};
|
|
657
|
+
try {
|
|
658
|
+
const { status, data } = await this._post(
|
|
659
|
+
PATHS.hub.actions(this.domain) + `/${encodeURIComponent(String(requestId))}/result`, body,
|
|
660
|
+
);
|
|
661
|
+
if (status === 200) {
|
|
662
|
+
return { ok: true, state: data.state || "", reasonCode: data.reason_code || "", message: data.message || "",
|
|
663
|
+
deduplicated: Boolean(data.deduplicated), error: null, httpStatus: status };
|
|
664
|
+
}
|
|
665
|
+
this.onError(new Error(`HTTP ${status}`), { op: "reportActionResult", code: data.error || "http_error" });
|
|
666
|
+
return { ok: false, state: data.state || "", error: data.error || `http_${status}`, httpStatus: status };
|
|
667
|
+
} catch (err) {
|
|
668
|
+
this.onError(err, { op: "reportActionResult", code: "network_error" });
|
|
669
|
+
return { ok: false, state: "", error: "network_error", httpStatus: 0 };
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** 상태 조회 — 원장 뷰(result 없음). 404 = 허브에 기록 없음(미도달 확정). */
|
|
674
|
+
async actionStatus(requestId) {
|
|
675
|
+
if (this.api !== "hub" || !requestId) return { ok: false, state: "", error: "unsupported" };
|
|
676
|
+
return this._getJson(
|
|
677
|
+
PATHS.hub.actions(this.domain) + `/${encodeURIComponent(String(requestId))}`,
|
|
678
|
+
"actionStatus",
|
|
679
|
+
(status, data) => {
|
|
680
|
+
if (status === 200) return { ok: true, state: data.state || "", reasonCode: data.reason_code || "",
|
|
681
|
+
message: data.message || "", error: null };
|
|
682
|
+
if (status === 404) return { ok: true, state: "none", reasonCode: "", message: "", error: null };
|
|
683
|
+
return { ok: false, state: "", error: data.error || `http_${status}` };
|
|
684
|
+
},
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 잠금 조회 — (action_key, target_key) 최근 성공. locked 면 params_hash·at·actor_claimed.
|
|
690
|
+
* 카드 잠금 판정은 호출자: locked && params_hash === offer.params_hash.
|
|
691
|
+
*/
|
|
692
|
+
async actionLock(actionKey, targetKey) {
|
|
693
|
+
if (this.api !== "hub" || !actionKey || !targetKey) return { ok: false, locked: false, error: "unsupported" };
|
|
694
|
+
const q = `?action_key=${encodeURIComponent(actionKey)}&target_key=${encodeURIComponent(targetKey)}`;
|
|
695
|
+
return this._getJson(PATHS.hub.actions(this.domain) + "/lock" + q, "actionLock", (status, data) => {
|
|
696
|
+
if (status === 200) {
|
|
697
|
+
return { ok: true, locked: Boolean(data.locked), paramsHash: data.params_hash || "",
|
|
698
|
+
requestId: data.request_id || "", at: data.at || "", actorClaimed: data.actor_claimed || "", error: null };
|
|
699
|
+
}
|
|
700
|
+
return { ok: false, locked: false, error: data.error || `http_${status}` };
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* 후보 스냅샷 발행 — "이 CMS 가 이미 하는 처리" (키·메서드·경로 템플릿, 호스트 없음).
|
|
706
|
+
* 같은 snapshot_hash 면 허브가 200 unchanged 로 쓰기 0. 신규/교체는 201.
|
|
707
|
+
* @param {{snapshotHash:string, items:Array<{endpoint_key:string, methods:string[], path_template:string}>}} snap
|
|
708
|
+
*/
|
|
709
|
+
async publishActionCandidates({ snapshotHash, items } = {}) {
|
|
710
|
+
if (this.api !== "hub" || !Array.isArray(items)) return { ok: false, error: "unsupported", httpStatus: 0 };
|
|
711
|
+
try {
|
|
712
|
+
const { status, data } = await this._post(PATHS.hub.actionCandidates(this.domain),
|
|
713
|
+
{ snapshot_hash: String(snapshotHash || ""), items });
|
|
714
|
+
if (status === 200 || status === 201) {
|
|
715
|
+
return { ok: true, unchanged: Boolean(data.unchanged), count: Number(data.count) || 0,
|
|
716
|
+
snapshotHash: data.snapshot_hash || "", error: null, httpStatus: status };
|
|
717
|
+
}
|
|
718
|
+
this.onError(new Error(`HTTP ${status}`), { op: "publishActionCandidates", code: data.error || "http_error" });
|
|
719
|
+
return { ok: false, error: data.error || `http_${status}`, httpStatus: status };
|
|
720
|
+
} catch (err) {
|
|
721
|
+
this.onError(err, { op: "publishActionCandidates", code: "network_error" });
|
|
722
|
+
return { ok: false, error: "network_error", httpStatus: 0 };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async _getJson(path, op, interpret) {
|
|
727
|
+
const controller = new AbortController();
|
|
728
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
729
|
+
try {
|
|
730
|
+
const res = await this.fetchImpl(this.baseUrl + path, {
|
|
731
|
+
headers: { "Authorization": `Bearer ${this.token}` }, signal: controller.signal,
|
|
732
|
+
});
|
|
733
|
+
const data = await res.json().catch(() => ({}));
|
|
734
|
+
return interpret(res.status, data);
|
|
735
|
+
} catch (err) {
|
|
736
|
+
this.onError(err, { op, code: "network_error" });
|
|
737
|
+
return { ok: false, state: "", error: "network_error" };
|
|
738
|
+
} finally {
|
|
739
|
+
clearTimeout(timer);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** ULID (Crockford 26자) — request_id 발급. 의존성 0 (공장 event_ids 와 동형). */
|
|
745
|
+
export function newRequestId(now = Date.now()) {
|
|
746
|
+
const A = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
747
|
+
let ts = BigInt(now) & ((1n << 48n) - 1n);
|
|
748
|
+
const rnd = new Uint8Array(10);
|
|
749
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) crypto.getRandomValues(rnd);
|
|
750
|
+
else for (let i = 0; i < 10; i++) rnd[i] = Math.floor(Math.random() * 256);
|
|
751
|
+
// 상위 48bit 시각 → 10자, 하위 80bit 무작위 → 16자 (공장 event_ids 와 동일 배치)
|
|
752
|
+
const out = [];
|
|
753
|
+
let t = ts;
|
|
754
|
+
for (let i = 0; i < 10; i++) { out.unshift(A[Number(t & 31n)]); t >>= 5n; }
|
|
755
|
+
let r = 0n;
|
|
756
|
+
for (const b of rnd) r = (r << 8n) | BigInt(b);
|
|
757
|
+
const tail = [];
|
|
758
|
+
for (let i = 0; i < 16; i++) { tail.unshift(A[Number(r & 31n)]); r >>= 5n; }
|
|
759
|
+
return out.join("") + tail.join("");
|
|
594
760
|
}
|
|
595
761
|
|
|
596
762
|
export default CxAgentClient;
|
package/element.d.ts
CHANGED
|
@@ -16,8 +16,19 @@ export interface CxAiSuggestElement extends HTMLElement {
|
|
|
16
16
|
/** 커스텀 엘리먼트 등록. 두 번 불러도 안전하고, 브라우저가 아니면 무동작(false). */
|
|
17
17
|
export declare function defineCxAiSuggest(tagName?: string): boolean;
|
|
18
18
|
|
|
19
|
+
/** 처리 선택지 카드 태그 이름 */
|
|
20
|
+
export declare const ACTIONS_TAG: "cx-action-offers";
|
|
21
|
+
export interface CxActionOffersElement extends HTMLElement {
|
|
22
|
+
agent: import("./agent.js").CxAgent | null;
|
|
23
|
+
session: import("./agent.js").InquirySession | null;
|
|
24
|
+
actorClaimed: string;
|
|
25
|
+
}
|
|
26
|
+
/** `<cx-action-offers>` 등록 — 결과는 `cx-action-result` 이벤트(detail = ActionResult) */
|
|
27
|
+
export declare function defineCxActionOffers(tagName?: string): boolean;
|
|
28
|
+
|
|
19
29
|
declare global {
|
|
20
|
-
interface HTMLElementTagNameMap { "cx-ai-suggest": CxAiSuggestElement }
|
|
30
|
+
interface HTMLElementTagNameMap { "cx-ai-suggest": CxAiSuggestElement; "cx-action-offers": CxActionOffersElement }
|
|
31
|
+
interface HTMLElementEventMap { "cx-action-result": CustomEvent<import("./agent.js").ActionResult & { requestId: string }> }
|
|
21
32
|
interface HTMLElementEventMap {
|
|
22
33
|
"cx-adopt": CustomEvent<{ text: string; answerId: number | string | null }>;
|
|
23
34
|
}
|
package/element.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* 클래스를 모듈 최상위에서 만들지 않는 이유: `HTMLElement` 가 없는 곳(SSR·Node)
|
|
25
25
|
* 에서 import 만 해도 죽는다. 등록 시점에 만든다.
|
|
26
26
|
*/
|
|
27
|
-
import { aiSuggestTree, aiSuggestView, CLS } from "./view.js";
|
|
27
|
+
import { actionOffersTree, aiSuggestTree, aiSuggestView, CLS } from "./view.js";
|
|
28
28
|
|
|
29
29
|
export { aiSuggestView };
|
|
30
30
|
|
|
@@ -137,3 +137,126 @@ export function defineCxAiSuggest(tagName = TAG) {
|
|
|
137
137
|
window.customElements.define(tagName, createClass());
|
|
138
138
|
return true;
|
|
139
139
|
}
|
|
140
|
+
|
|
141
|
+
// ── 처리 선택지 카드 — 표준 커스텀 엘리먼트 `<cx-action-offers>` ─────────────
|
|
142
|
+
//
|
|
143
|
+
// ```html
|
|
144
|
+
// <cx-action-offers></cx-action-offers>
|
|
145
|
+
// <script type="module">
|
|
146
|
+
// import { defineCxActionOffers } from "@fcg-labs/cx-agent-hook/element";
|
|
147
|
+
// defineCxActionOffers();
|
|
148
|
+
// const el = document.querySelector("cx-action-offers");
|
|
149
|
+
// el.agent = agent; el.session = agent.session(csId); el.actorClaimed = "상담사 이름";
|
|
150
|
+
// el.addEventListener("cx-action-result", (e) => console.log(e.detail.state));
|
|
151
|
+
// </script>
|
|
152
|
+
// ```
|
|
153
|
+
export const ACTIONS_TAG = "cx-action-offers";
|
|
154
|
+
|
|
155
|
+
function createActionsClass() {
|
|
156
|
+
return class CxActionOffersElement extends HTMLElement {
|
|
157
|
+
#agent = null;
|
|
158
|
+
#session = null;
|
|
159
|
+
#actorClaimed = "";
|
|
160
|
+
#offers = [];
|
|
161
|
+
#pending = [];
|
|
162
|
+
#confirming = null;
|
|
163
|
+
#ackedOffer = null; // 고위험 확인은 offer 단위
|
|
164
|
+
|
|
165
|
+
get agent() { return this.#agent; }
|
|
166
|
+
set agent(v) { this.#agent = v; this.#render(); }
|
|
167
|
+
get actorClaimed() { return this.#actorClaimed; }
|
|
168
|
+
set actorClaimed(v) { this.#actorClaimed = v == null ? "" : String(v); }
|
|
169
|
+
get session() { return this.#session; }
|
|
170
|
+
set session(s) {
|
|
171
|
+
if (this.#session && typeof this.#session.detachActionUi === "function") this.#session.detachActionUi();
|
|
172
|
+
this.#session = s || null;
|
|
173
|
+
this.#confirming = null; this.#ackedOffer = null;
|
|
174
|
+
this.#attach();
|
|
175
|
+
this.#render();
|
|
176
|
+
}
|
|
177
|
+
// sink 연결 — session setter 와 재연결(connectedCallback) 양쪽에서. detach 후 다시 DOM 에
|
|
178
|
+
// 붙는 엘리먼트가 화석 스냅샷으로 남지 않게 한다.
|
|
179
|
+
#attach() {
|
|
180
|
+
const s = this.#session;
|
|
181
|
+
if (s && typeof s.attachActionUi === "function") {
|
|
182
|
+
this.#offers = s.offers; this.#pending = s.pendingActions();
|
|
183
|
+
s.attachActionUi({ setOffers: (o, p) => { this.#offers = o; this.#pending = p; this.#render(); } });
|
|
184
|
+
} else { this.#offers = []; this.#pending = []; }
|
|
185
|
+
}
|
|
186
|
+
connectedCallback() { this.#attach(); this.#render(); }
|
|
187
|
+
disconnectedCallback() { if (this.#session && typeof this.#session.detachActionUi === "function") this.#session.detachActionUi(); }
|
|
188
|
+
|
|
189
|
+
async #execute(offerId) {
|
|
190
|
+
const offer = this.#offers.find((o) => o.offer_id === offerId);
|
|
191
|
+
if (!offer || !this.#session) return;
|
|
192
|
+
if (offer.risk_level === "high" && this.#ackedOffer !== offerId) return;
|
|
193
|
+
this.#confirming = null; this.#ackedOffer = null; this.#render();
|
|
194
|
+
const r = await this.#session.executeAction(offer, { actorClaimed: this.#actorClaimed });
|
|
195
|
+
this.#render();
|
|
196
|
+
this.dispatchEvent(new CustomEvent("cx-action-result", { detail: r, bubbles: true }));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#render() {
|
|
200
|
+
// 포커스 복원 — DOM 을 갈아 끼우므로 눌린 컨트롤(data-offer/data-request + action)을 다시 찾아 준다
|
|
201
|
+
const active = document.activeElement;
|
|
202
|
+
const focusKey = active && this.contains(active)
|
|
203
|
+
? { action: active.dataset.action || "", offer: active.dataset.offer || "", request: active.dataset.request || "" } : null;
|
|
204
|
+
this.replaceChildren();
|
|
205
|
+
if (!this.#agent || !this.#session) return;
|
|
206
|
+
const tree = actionOffersTree({ offers: this.#offers, pending: this.#pending,
|
|
207
|
+
enabled: Boolean(this.#agent.actionsEnabled), confirming: this.#confirming, messages: this.#agent.messages });
|
|
208
|
+
if (!tree) return;
|
|
209
|
+
const actions = {
|
|
210
|
+
confirm: (e) => { this.#confirming = e.currentTarget.dataset.offer || null; this.#ackedOffer = null; this.#render(); },
|
|
211
|
+
cancel: () => { this.#confirming = null; this.#ackedOffer = null; this.#render(); },
|
|
212
|
+
ack: (e) => { const id = e.currentTarget.dataset.offer; this.#ackedOffer = this.#ackedOffer === id ? null : id; this.#render(); },
|
|
213
|
+
execute: (e) => this.#execute(e.currentTarget.dataset.offer),
|
|
214
|
+
resend: (e) => this.#session.resendResult(e.currentTarget.dataset.request),
|
|
215
|
+
};
|
|
216
|
+
const build = (node) => {
|
|
217
|
+
const dom = document.createElement(node.tag);
|
|
218
|
+
if (node.cls) dom.className = node.cls;
|
|
219
|
+
if (node.tag === "button") dom.type = node.type || "button";
|
|
220
|
+
if (node.action && actions[node.action]) {
|
|
221
|
+
dom.addEventListener("click", actions[node.action]);
|
|
222
|
+
dom.dataset.action = node.action;
|
|
223
|
+
if (node.offerId) dom.dataset.offer = node.offerId;
|
|
224
|
+
if (node.requestId) dom.dataset.request = node.requestId;
|
|
225
|
+
if (node.action === "execute" && this.#confirming) {
|
|
226
|
+
const o = this.#offers.find((x) => x.offer_id === node.offerId);
|
|
227
|
+
if (o && o.risk_level === "high" && this.#ackedOffer !== node.offerId) dom.disabled = true;
|
|
228
|
+
}
|
|
229
|
+
if (node.pressed) dom.setAttribute("aria-pressed", String(this.#ackedOffer === node.offerId));
|
|
230
|
+
}
|
|
231
|
+
if (node.live) dom.setAttribute("role", "status");
|
|
232
|
+
if (node.state) dom.dataset.state = node.state;
|
|
233
|
+
if (node.risk) dom.dataset.risk = node.risk;
|
|
234
|
+
if (node.children) for (const c of node.children) dom.append(build(c));
|
|
235
|
+
else if (node.text !== undefined) dom.textContent = node.text;
|
|
236
|
+
return dom;
|
|
237
|
+
};
|
|
238
|
+
const root = document.createElement("div");
|
|
239
|
+
root.className = tree.cls;
|
|
240
|
+
root.addEventListener("click", (e) => e.stopPropagation());
|
|
241
|
+
for (const n of tree.children) root.append(build(n));
|
|
242
|
+
this.append(root);
|
|
243
|
+
if (focusKey) {
|
|
244
|
+
// 같은 컨트롤이 남아 있으면 그것, 확인 패널을 연 직후면 패널의 첫 버튼(실행/체크)로
|
|
245
|
+
const same = focusKey.action && [...root.querySelectorAll("[data-action]")]
|
|
246
|
+
.find((el) => el.dataset.action === focusKey.action && (el.dataset.offer || "") === focusKey.offer && (el.dataset.request || "") === focusKey.request && !el.disabled);
|
|
247
|
+
const target = same
|
|
248
|
+
|| (focusKey.action === "confirm" && root.querySelector(`.${"fcx-act-confirm"} button:not(:disabled)`))
|
|
249
|
+
|| null;
|
|
250
|
+
if (target && typeof target.focus === "function") target.focus();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 커스텀 엘리먼트 등록 — 두 번 불러도 안전, 브라우저가 아니면 무동작. */
|
|
257
|
+
export function defineCxActionOffers(tagName = ACTIONS_TAG) {
|
|
258
|
+
if (typeof window === "undefined" || !window.customElements) return false;
|
|
259
|
+
if (window.customElements.get(tagName)) return false;
|
|
260
|
+
window.customElements.define(tagName, createActionsClass());
|
|
261
|
+
return true;
|
|
262
|
+
}
|
package/locales.js
CHANGED
|
@@ -31,6 +31,23 @@ const en = {
|
|
|
31
31
|
ui_adopt: "Insert into editor",
|
|
32
32
|
ui_evidence: "Sources",
|
|
33
33
|
ui_correcting: "Fixing phrasing — rewriting…",
|
|
34
|
+
ui_action_execute: "Process",
|
|
35
|
+
ui_action_confirm_default: "Run this action?",
|
|
36
|
+
ui_action_confirm_ok: "Yes, run",
|
|
37
|
+
ui_action_confirm_cancel: "Cancel",
|
|
38
|
+
ui_action_ack: "I understand this cannot be undone",
|
|
39
|
+
ui_action_running: "Processing…",
|
|
40
|
+
ui_action_succeeded: "Done",
|
|
41
|
+
ui_action_failed: "Not applied",
|
|
42
|
+
ui_action_unknown: "Could not confirm whether this was applied — check again before retrying.",
|
|
43
|
+
ui_action_resend: "Report result again",
|
|
44
|
+
ui_action_unreported: "result not yet recorded",
|
|
45
|
+
ui_action_locked: "Already processed",
|
|
46
|
+
ui_action_unavailable: "This CMS does not know this action — ask your CMS admin to update the hook.",
|
|
47
|
+
ui_action_disabled: "Actions are not enabled for this seat.",
|
|
48
|
+
ui_action_risk_low: "low risk",
|
|
49
|
+
ui_action_risk_medium: "medium risk",
|
|
50
|
+
ui_action_risk_high: "high risk",
|
|
34
51
|
ui_overwrite_confirm: "Replace your current draft with the AI draft?",
|
|
35
52
|
// 거절·오류 사유
|
|
36
53
|
not_configured: "AI reply suggestions are not connected yet.",
|
|
@@ -57,6 +74,23 @@ const ko = {
|
|
|
57
74
|
ui_adopt: "에디터에 넣기",
|
|
58
75
|
ui_evidence: "근거",
|
|
59
76
|
ui_correcting: "표현 교정 중 — 다시 쓰는 중...",
|
|
77
|
+
ui_action_execute: "처리하기",
|
|
78
|
+
ui_action_confirm_default: "이 처리를 진행할까요?",
|
|
79
|
+
ui_action_confirm_ok: "네, 진행",
|
|
80
|
+
ui_action_confirm_cancel: "취소",
|
|
81
|
+
ui_action_ack: "되돌릴 수 없음을 확인했습니다",
|
|
82
|
+
ui_action_running: "처리 중…",
|
|
83
|
+
ui_action_succeeded: "처리 완료",
|
|
84
|
+
ui_action_failed: "처리되지 않음",
|
|
85
|
+
ui_action_unknown: "실행 여부를 확인하지 못했습니다 — 다시 누르지 말고 먼저 확인하세요.",
|
|
86
|
+
ui_action_resend: "결과 다시 보고",
|
|
87
|
+
ui_action_unreported: "결과가 아직 기록되지 않음",
|
|
88
|
+
ui_action_locked: "이미 처리됨",
|
|
89
|
+
ui_action_unavailable: "이 CMS 에 없는 처리입니다 — CMS 담당자에게 훅 갱신을 요청하세요.",
|
|
90
|
+
ui_action_disabled: "이 계정에는 처리 기능이 켜져 있지 않습니다.",
|
|
91
|
+
ui_action_risk_low: "위험 낮음",
|
|
92
|
+
ui_action_risk_medium: "위험 보통",
|
|
93
|
+
ui_action_risk_high: "위험 높음",
|
|
60
94
|
ui_overwrite_confirm: "작성 중인 답변을 지우고 AI 초안으로 바꿀까요?",
|
|
61
95
|
not_configured: "AI 답변 제안이 아직 연결되지 않았습니다.",
|
|
62
96
|
unsupported_api: "AI 답변 제안이 아직 연결되지 않았습니다.",
|
|
@@ -78,6 +112,23 @@ const ja = {
|
|
|
78
112
|
ui_adopt: "エディタに挿入",
|
|
79
113
|
ui_evidence: "根拠",
|
|
80
114
|
ui_correcting: "表現を修正中 — 書き直しています…",
|
|
115
|
+
ui_action_execute: "処理する",
|
|
116
|
+
ui_action_confirm_default: "この処理を実行しますか?",
|
|
117
|
+
ui_action_confirm_ok: "はい、実行",
|
|
118
|
+
ui_action_confirm_cancel: "キャンセル",
|
|
119
|
+
ui_action_ack: "元に戻せないことを確認しました",
|
|
120
|
+
ui_action_running: "処理中…",
|
|
121
|
+
ui_action_succeeded: "処理完了",
|
|
122
|
+
ui_action_failed: "未処理",
|
|
123
|
+
ui_action_unknown: "実行の有無を確認できませんでした — 再度押さずに先に確認してください。",
|
|
124
|
+
ui_action_resend: "結果を再報告",
|
|
125
|
+
ui_action_unreported: "結果がまだ記録されていません",
|
|
126
|
+
ui_action_locked: "処理済み",
|
|
127
|
+
ui_action_unavailable: "このCMSにない処理です — CMS担当者にフック更新を依頼してください。",
|
|
128
|
+
ui_action_disabled: "このアカウントでは処理機能が有効になっていません。",
|
|
129
|
+
ui_action_risk_low: "リスク低",
|
|
130
|
+
ui_action_risk_medium: "リスク中",
|
|
131
|
+
ui_action_risk_high: "リスク高",
|
|
81
132
|
ui_overwrite_confirm: "作成中の回答を消してAI下書きに置き換えますか?",
|
|
82
133
|
not_configured: "AI 返信案はまだ接続されていません。",
|
|
83
134
|
unsupported_api: "AI 返信案はまだ接続されていません。",
|
|
@@ -99,6 +150,23 @@ const zhTW = {
|
|
|
99
150
|
ui_adopt: "插入編輯器",
|
|
100
151
|
ui_evidence: "依據",
|
|
101
152
|
ui_correcting: "正在修正表述 — 重新撰寫中…",
|
|
153
|
+
ui_action_execute: "處理",
|
|
154
|
+
ui_action_confirm_default: "要執行這項處理嗎?",
|
|
155
|
+
ui_action_confirm_ok: "是,執行",
|
|
156
|
+
ui_action_confirm_cancel: "取消",
|
|
157
|
+
ui_action_ack: "我已確認此操作無法復原",
|
|
158
|
+
ui_action_running: "處理中…",
|
|
159
|
+
ui_action_succeeded: "處理完成",
|
|
160
|
+
ui_action_failed: "未套用",
|
|
161
|
+
ui_action_unknown: "無法確認是否已執行 — 請先確認再重試。",
|
|
162
|
+
ui_action_resend: "重新回報結果",
|
|
163
|
+
ui_action_unreported: "結果尚未記錄",
|
|
164
|
+
ui_action_locked: "已處理",
|
|
165
|
+
ui_action_unavailable: "此 CMS 沒有這個處理 — 請 CMS 負責人更新掛鉤。",
|
|
166
|
+
ui_action_disabled: "此帳號未啟用處理功能。",
|
|
167
|
+
ui_action_risk_low: "風險低",
|
|
168
|
+
ui_action_risk_medium: "風險中",
|
|
169
|
+
ui_action_risk_high: "風險高",
|
|
102
170
|
ui_overwrite_confirm: "要清除目前草稿並以 AI 草稿取代嗎?",
|
|
103
171
|
not_configured: "AI 回覆建議尚未連接。",
|
|
104
172
|
unsupported_api: "AI 回覆建議尚未連接。",
|
package/next.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
/** Next.js (App Router) 진입점 — react 와 같되 "use client" 경계를 선언한다. */
|
|
2
|
-
export { AiSuggestPanel, aiSuggestView, default } from "./react.js";
|
|
3
|
-
export type { AiSuggestPanelProps, AiSuggestView } from "./react.js";
|
|
2
|
+
export { AiSuggestPanel, ActionOffersPanel, aiSuggestView, default } from "./react.js";
|
|
3
|
+
export type { AiSuggestPanelProps, ActionOffersPanelProps, AiSuggestView } from "./react.js";
|
package/next.js
CHANGED
|
@@ -17,4 +17,4 @@
|
|
|
17
17
|
* Pages Router 나 순수 React 라면 `@fcg-labs/cx-agent-hook/react` 와 같다.
|
|
18
18
|
* 지시자는 그쪽에서 무시되므로 이 진입점을 써도 문제는 없다.
|
|
19
19
|
*/
|
|
20
|
-
export { AiSuggestPanel, aiSuggestView, default } from "./react.js";
|
|
20
|
+
export { AiSuggestPanel, ActionOffersPanel, aiSuggestView, default } from "./react.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fcg-labs/cx-agent-hook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"agent.js",
|
|
62
62
|
"agent.d.ts",
|
|
63
63
|
"session.js",
|
|
64
|
+
"actions.js",
|
|
64
65
|
"compat.js",
|
|
65
66
|
"client.js",
|
|
66
67
|
"client.d.ts",
|
|
@@ -83,7 +84,10 @@
|
|
|
83
84
|
"element.d.ts",
|
|
84
85
|
"styles.css",
|
|
85
86
|
"README.md",
|
|
86
|
-
"
|
|
87
|
+
"AGENTS.md",
|
|
88
|
+
"LICENSE",
|
|
89
|
+
"bin/",
|
|
90
|
+
"skills/"
|
|
87
91
|
],
|
|
88
92
|
"scripts": {
|
|
89
93
|
"test": "node --test test/*.test.js",
|
|
@@ -108,5 +112,8 @@
|
|
|
108
112
|
"publishConfig": {
|
|
109
113
|
"access": "public"
|
|
110
114
|
},
|
|
111
|
-
"//name": "스코프는 @fcg-labs — org 소유이고 @fcg-labs/tlm 과 같은 자리다. @fcg 는 우리 것이 아니다(발행 불가)."
|
|
112
|
-
|
|
115
|
+
"//name": "스코프는 @fcg-labs — org 소유이고 @fcg-labs/tlm 과 같은 자리다. @fcg 는 우리 것이 아니다(발행 불가).",
|
|
116
|
+
"bin": {
|
|
117
|
+
"cx-agent-hook": "bin/cx-agent-hook.js"
|
|
118
|
+
}
|
|
119
|
+
}
|
package/react.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
2
|
import type { AnswerResult, CxHook } from "./index.js";
|
|
3
|
+
import type { ActionResult, CxAgent, InquirySession } from "./agent.js";
|
|
3
4
|
|
|
4
5
|
export { aiSuggestView } from "./view.js";
|
|
5
6
|
export type { AiSuggestView } from "./view.js";
|
|
@@ -17,4 +18,17 @@ export interface AiSuggestPanelProps {
|
|
|
17
18
|
/** AI 답변 제안 패널. hook 이 미설정이면 아무것도 그리지 않는다. */
|
|
18
19
|
export declare function AiSuggestPanel(props: AiSuggestPanelProps): ReactElement | null;
|
|
19
20
|
|
|
21
|
+
|
|
22
|
+
export interface ActionOffersPanelProps {
|
|
23
|
+
/** agent.session(externalId) — offers·pending·executeAction 의 소유자 */
|
|
24
|
+
session: InquirySession;
|
|
25
|
+
agent: CxAgent;
|
|
26
|
+
/** 표시용 행위자 이름(자기 주장) — 원장 actor_claimed */
|
|
27
|
+
actorClaimed?: string;
|
|
28
|
+
onResult?: (r: ActionResult & { requestId: string }) => void;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
/** 처리 선택지 카드 (actions/1 프로파일 B) — 잠김·비활성·2단 확인·상태·미보고 규약을 갖는다. */
|
|
32
|
+
export declare function ActionOffersPanel(props: ActionOffersPanelProps): ReactElement | null;
|
|
33
|
+
|
|
20
34
|
export default AiSuggestPanel;
|