@xbrain-swe/nova-chat-widget 0.1.9 → 0.1.11

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/dist/index.d.cts CHANGED
@@ -80,6 +80,12 @@ type XbNovaClientAction = {
80
80
  type XbNovaClientActions = Record<string, XbNovaClientAction>;
81
81
 
82
82
  type FactValue = unknown | (() => unknown);
83
+ /** `useXbNovaContext`로 호스트가 AI에게 알려주는 상품·화면 부가 설명 한 건. 내용은 전부
84
+ * 호스트 자율이다 — 가입 됨/안됨 같은 판단도 호스트가 `description`에 문장으로 쓴다. */
85
+ type XbNovaContextEntry = {
86
+ title: string;
87
+ description: string;
88
+ };
83
89
  /** 위젯 아바타·pill 배경 그라디언트에 쓰는 두 색상 — 기본값은 SDK 자체 톤(인디고→바이올렛)
84
90
  * 이고, 소비자가 `<XbNovaProvider theme={{accentFrom, accentTo}}>`로 자기 브랜드 컬러를
85
91
  * 넘기면 그걸 쓴다("카통령 브랜드 컬러를 따르게 해달라" — 파랑/보라가 사이트 톤과 안
@@ -116,11 +122,15 @@ type ClientResults = Record<string, {
116
122
  output: unknown;
117
123
  }>;
118
124
  type XbNovaFeedbackRating = "up" | "down" | null;
125
+ /** 👎 사유 칩의 코드. */
126
+ type XbNovaFeedbackReason = "inaccurate" | "irrelevant" | "too_long" | "unclear";
119
127
  /** 답변 만족도 저장 성공 후 호스트로 전달되는 이벤트. `rating: null`은 평가 취소. */
120
128
  type XbNovaFeedbackEvent = {
121
129
  conversationId: string;
122
130
  messageId: string;
123
131
  rating: XbNovaFeedbackRating;
132
+ /** 👎일 때 사용자가 고른 사유 코드(없으면 undefined). */
133
+ reason?: XbNovaFeedbackReason;
124
134
  };
125
135
  type XbNovaOnboarding = {
126
136
  conversation_id: string;
@@ -143,7 +153,7 @@ type UseXbNovaChatResult = {
143
153
  /** messageId → 현재 평가. 평가 안 한 메시지는 키가 없다. */
144
154
  feedback: Record<string, XbNovaFeedbackRating>;
145
155
  /** 낙관적으로 반영하고 서버에 저장한다. 실패하면 되돌린다. */
146
- setFeedback: (messageId: string, rating: XbNovaFeedbackRating) => Promise<void>;
156
+ setFeedback: (messageId: string, rating: XbNovaFeedbackRating, reason?: XbNovaFeedbackReason) => Promise<void>;
147
157
  };
148
158
  /** `botId`를 넘기면 Provider의 기본 봇 대신 그 봇으로 새 대화를 연다 — 넛지 handoff가 쓴다.
149
159
  * `nudgeNodeId`를 넘기면 이 대화가 그 넛지에서 시작됐다고 서버에 표시한다 — 넛지 전환
@@ -154,6 +164,10 @@ type UseXbNovaChatResult = {
154
164
  declare function useXbNovaChat(options?: {
155
165
  botId?: string;
156
166
  nudgeNodeId?: string;
167
+ /** 위젯이 첫 말풍선으로 보여준 문구(넛지 문구). 첫 메시지를 보낼 때 서버에 같이 보내 AI가
168
+ * "방금 이런 말을 건넸다"는 걸 알게 한다 — 없으면 봇 인사말을 쓴다. 서버가 설정과 일치하는지
169
+ * 검증해서 맞는 것만 이력에 저장한다. */
170
+ openingMessage?: string;
157
171
  onDismissSheet?: () => void;
158
172
  onFeedback?: (e: XbNovaFeedbackEvent) => void;
159
173
  }): UseXbNovaChatResult;
@@ -245,6 +259,11 @@ declare function useXbNovaFact(key: string, value: FactValue): void;
245
259
  /** 필드가 여러 개인 객체 하나를 한 번에 등록한다 — 매물 데이터처럼. `useXbNovaFact`를
246
260
  * 필드마다 반복하는 것과 같다. */
247
261
  declare function useXbNovaFacts(values: Record<string, FactValue>): void;
262
+ /** AI에게 알려줄 상품·화면 부가 설명을 등록한다. 넛지 조건용인 facts와 달리 **챗 요청의
263
+ * `context`로 전송**되어 답변의 근거가 된다. 내용(가입 가능 여부 같은 판단 포함)은 호스트가
264
+ * `description`에 문장으로 쓴다 — SDK는 형식 검증과 전달만 한다. 배열 또는 함수(pull) 둘 다
265
+ * 된다. 컴포넌트가 언마운트되면 자동으로 빠진다. */
266
+ declare function useXbNovaContext(entries: XbNovaContextEntry[] | (() => XbNovaContextEntry[])): void;
248
267
  /** 방문자 식별자 — 없으면 SDK가 즉시 만들어 localStorage에 고정한 뒤 그 값을 반환한다. */
249
268
  declare function useXbNovaVisitor(): string;
250
269
  /** 선언형 — 서버 컴포넌트 트리에서 훅을 못 쓸 때. `<XbNovaFact id="xbnova_summary" value="..." />`. */
@@ -270,4 +289,4 @@ declare function useXbNovaSettings(): {
270
289
  showBar: () => void;
271
290
  };
272
291
 
273
- export { type ChatMessage, type FactValue, type MessagePart, type ProductItem, type TextPart, type ToolPart, type UseXbNovaChatResult, type VerdictAction, type VerdictOutput, type ViewKind, type ViewRenderer, type ViewRendererMap, XbNovaChat, type XbNovaClientAction, type XbNovaClientActionResult, type XbNovaClientActions, XbNovaFact, XbNovaFacts, type XbNovaFeedbackEvent, type XbNovaFeedbackRating, type XbNovaOnboarding, XbNovaProvider, type XbNovaTheme, XbNovaTrack, trackXbNovaEvent, useXbNovaChat, useXbNovaFact, useXbNovaFacts, useXbNovaSettings, useXbNovaTrack, useXbNovaVisitor };
292
+ export { type ChatMessage, type FactValue, type MessagePart, type ProductItem, type TextPart, type ToolPart, type UseXbNovaChatResult, type VerdictAction, type VerdictOutput, type ViewKind, type ViewRenderer, type ViewRendererMap, XbNovaChat, type XbNovaClientAction, type XbNovaClientActionResult, type XbNovaClientActions, type XbNovaContextEntry, XbNovaFact, XbNovaFacts, type XbNovaFeedbackEvent, type XbNovaFeedbackRating, type XbNovaFeedbackReason, type XbNovaOnboarding, XbNovaProvider, type XbNovaTheme, XbNovaTrack, trackXbNovaEvent, useXbNovaChat, useXbNovaContext, useXbNovaFact, useXbNovaFacts, useXbNovaSettings, useXbNovaTrack, useXbNovaVisitor };
package/dist/index.d.ts CHANGED
@@ -80,6 +80,12 @@ type XbNovaClientAction = {
80
80
  type XbNovaClientActions = Record<string, XbNovaClientAction>;
81
81
 
82
82
  type FactValue = unknown | (() => unknown);
83
+ /** `useXbNovaContext`로 호스트가 AI에게 알려주는 상품·화면 부가 설명 한 건. 내용은 전부
84
+ * 호스트 자율이다 — 가입 됨/안됨 같은 판단도 호스트가 `description`에 문장으로 쓴다. */
85
+ type XbNovaContextEntry = {
86
+ title: string;
87
+ description: string;
88
+ };
83
89
  /** 위젯 아바타·pill 배경 그라디언트에 쓰는 두 색상 — 기본값은 SDK 자체 톤(인디고→바이올렛)
84
90
  * 이고, 소비자가 `<XbNovaProvider theme={{accentFrom, accentTo}}>`로 자기 브랜드 컬러를
85
91
  * 넘기면 그걸 쓴다("카통령 브랜드 컬러를 따르게 해달라" — 파랑/보라가 사이트 톤과 안
@@ -116,11 +122,15 @@ type ClientResults = Record<string, {
116
122
  output: unknown;
117
123
  }>;
118
124
  type XbNovaFeedbackRating = "up" | "down" | null;
125
+ /** 👎 사유 칩의 코드. */
126
+ type XbNovaFeedbackReason = "inaccurate" | "irrelevant" | "too_long" | "unclear";
119
127
  /** 답변 만족도 저장 성공 후 호스트로 전달되는 이벤트. `rating: null`은 평가 취소. */
120
128
  type XbNovaFeedbackEvent = {
121
129
  conversationId: string;
122
130
  messageId: string;
123
131
  rating: XbNovaFeedbackRating;
132
+ /** 👎일 때 사용자가 고른 사유 코드(없으면 undefined). */
133
+ reason?: XbNovaFeedbackReason;
124
134
  };
125
135
  type XbNovaOnboarding = {
126
136
  conversation_id: string;
@@ -143,7 +153,7 @@ type UseXbNovaChatResult = {
143
153
  /** messageId → 현재 평가. 평가 안 한 메시지는 키가 없다. */
144
154
  feedback: Record<string, XbNovaFeedbackRating>;
145
155
  /** 낙관적으로 반영하고 서버에 저장한다. 실패하면 되돌린다. */
146
- setFeedback: (messageId: string, rating: XbNovaFeedbackRating) => Promise<void>;
156
+ setFeedback: (messageId: string, rating: XbNovaFeedbackRating, reason?: XbNovaFeedbackReason) => Promise<void>;
147
157
  };
148
158
  /** `botId`를 넘기면 Provider의 기본 봇 대신 그 봇으로 새 대화를 연다 — 넛지 handoff가 쓴다.
149
159
  * `nudgeNodeId`를 넘기면 이 대화가 그 넛지에서 시작됐다고 서버에 표시한다 — 넛지 전환
@@ -154,6 +164,10 @@ type UseXbNovaChatResult = {
154
164
  declare function useXbNovaChat(options?: {
155
165
  botId?: string;
156
166
  nudgeNodeId?: string;
167
+ /** 위젯이 첫 말풍선으로 보여준 문구(넛지 문구). 첫 메시지를 보낼 때 서버에 같이 보내 AI가
168
+ * "방금 이런 말을 건넸다"는 걸 알게 한다 — 없으면 봇 인사말을 쓴다. 서버가 설정과 일치하는지
169
+ * 검증해서 맞는 것만 이력에 저장한다. */
170
+ openingMessage?: string;
157
171
  onDismissSheet?: () => void;
158
172
  onFeedback?: (e: XbNovaFeedbackEvent) => void;
159
173
  }): UseXbNovaChatResult;
@@ -245,6 +259,11 @@ declare function useXbNovaFact(key: string, value: FactValue): void;
245
259
  /** 필드가 여러 개인 객체 하나를 한 번에 등록한다 — 매물 데이터처럼. `useXbNovaFact`를
246
260
  * 필드마다 반복하는 것과 같다. */
247
261
  declare function useXbNovaFacts(values: Record<string, FactValue>): void;
262
+ /** AI에게 알려줄 상품·화면 부가 설명을 등록한다. 넛지 조건용인 facts와 달리 **챗 요청의
263
+ * `context`로 전송**되어 답변의 근거가 된다. 내용(가입 가능 여부 같은 판단 포함)은 호스트가
264
+ * `description`에 문장으로 쓴다 — SDK는 형식 검증과 전달만 한다. 배열 또는 함수(pull) 둘 다
265
+ * 된다. 컴포넌트가 언마운트되면 자동으로 빠진다. */
266
+ declare function useXbNovaContext(entries: XbNovaContextEntry[] | (() => XbNovaContextEntry[])): void;
248
267
  /** 방문자 식별자 — 없으면 SDK가 즉시 만들어 localStorage에 고정한 뒤 그 값을 반환한다. */
249
268
  declare function useXbNovaVisitor(): string;
250
269
  /** 선언형 — 서버 컴포넌트 트리에서 훅을 못 쓸 때. `<XbNovaFact id="xbnova_summary" value="..." />`. */
@@ -270,4 +289,4 @@ declare function useXbNovaSettings(): {
270
289
  showBar: () => void;
271
290
  };
272
291
 
273
- export { type ChatMessage, type FactValue, type MessagePart, type ProductItem, type TextPart, type ToolPart, type UseXbNovaChatResult, type VerdictAction, type VerdictOutput, type ViewKind, type ViewRenderer, type ViewRendererMap, XbNovaChat, type XbNovaClientAction, type XbNovaClientActionResult, type XbNovaClientActions, XbNovaFact, XbNovaFacts, type XbNovaFeedbackEvent, type XbNovaFeedbackRating, type XbNovaOnboarding, XbNovaProvider, type XbNovaTheme, XbNovaTrack, trackXbNovaEvent, useXbNovaChat, useXbNovaFact, useXbNovaFacts, useXbNovaSettings, useXbNovaTrack, useXbNovaVisitor };
292
+ export { type ChatMessage, type FactValue, type MessagePart, type ProductItem, type TextPart, type ToolPart, type UseXbNovaChatResult, type VerdictAction, type VerdictOutput, type ViewKind, type ViewRenderer, type ViewRendererMap, XbNovaChat, type XbNovaClientAction, type XbNovaClientActionResult, type XbNovaClientActions, type XbNovaContextEntry, XbNovaFact, XbNovaFacts, type XbNovaFeedbackEvent, type XbNovaFeedbackRating, type XbNovaFeedbackReason, type XbNovaOnboarding, XbNovaProvider, type XbNovaTheme, XbNovaTrack, trackXbNovaEvent, useXbNovaChat, useXbNovaContext, useXbNovaFact, useXbNovaFacts, useXbNovaSettings, useXbNovaTrack, useXbNovaVisitor };
package/dist/index.js CHANGED
@@ -40,6 +40,17 @@ function resolveVisitorId() {
40
40
  function resolveFactValue(value) {
41
41
  return typeof value === "function" ? value() : value;
42
42
  }
43
+ function collectContextEntries(registry) {
44
+ const out = [];
45
+ for (const source of registry.values()) {
46
+ const entries = typeof source === "function" ? source() : source;
47
+ if (!Array.isArray(entries)) continue;
48
+ for (const e of entries) {
49
+ if (e && typeof e.title === "string" && typeof e.description === "string") out.push(e);
50
+ }
51
+ }
52
+ return out;
53
+ }
43
54
  function InternalProvider({
44
55
  auth,
45
56
  clientActionHandlers,
@@ -48,6 +59,7 @@ function InternalProvider({
48
59
  }) {
49
60
  const trackRegistry = useRef(/* @__PURE__ */ new Map()).current;
50
61
  const factsRegistry = useRef(/* @__PURE__ */ new Map()).current;
62
+ const contextRegistry = useRef(/* @__PURE__ */ new Map()).current;
51
63
  const emptyHandlers = useRef(/* @__PURE__ */ new Map()).current;
52
64
  const visitorIdRef = useRef(null);
53
65
  if (visitorIdRef.current === null) {
@@ -58,11 +70,12 @@ function InternalProvider({
58
70
  auth,
59
71
  trackRegistry,
60
72
  factsRegistry,
73
+ contextRegistry,
61
74
  clientActionHandlers: clientActionHandlers ?? emptyHandlers,
62
75
  visitorId: visitorIdRef.current,
63
76
  theme
64
77
  }),
65
- [auth, trackRegistry, factsRegistry, clientActionHandlers, emptyHandlers, theme]
78
+ [auth, trackRegistry, factsRegistry, contextRegistry, clientActionHandlers, emptyHandlers, theme]
66
79
  );
67
80
  return /* @__PURE__ */ jsx(InternalContext.Provider, { value, children });
68
81
  }
@@ -1089,7 +1102,7 @@ async function resumeToolResult(apiBaseUrl, apiKey, body, handlers) {
1089
1102
  // src/use-chat.ts
1090
1103
  var DEFAULT_CLIENT_ACTION_TIMEOUT_MS = 15e3;
1091
1104
  function useXbNovaChat(options) {
1092
- const { auth, trackRegistry, clientActionHandlers, visitorId } = useXbNovaInternal();
1105
+ const { auth, trackRegistry, contextRegistry, clientActionHandlers, visitorId } = useXbNovaInternal();
1093
1106
  const botId = options?.botId ?? auth.botId;
1094
1107
  const nudgeNodeId = options?.nudgeNodeId;
1095
1108
  const { apiKey, apiBaseUrl } = auth;
@@ -1128,7 +1141,7 @@ function useXbNovaChat(options) {
1128
1141
  next[next.length - 1] = { ...next[next.length - 1], id };
1129
1142
  return next;
1130
1143
  });
1131
- async function setFeedback(messageId, rating) {
1144
+ async function setFeedback(messageId, rating, reason) {
1132
1145
  const conversationId = onboarding?.conversation_id;
1133
1146
  if (!conversationId || !messageId) return;
1134
1147
  const prevRating = feedbackRef.current[messageId] ?? null;
@@ -1145,11 +1158,11 @@ function useXbNovaChat(options) {
1145
1158
  {
1146
1159
  method: "PUT",
1147
1160
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
1148
- body: JSON.stringify({ rating, visitor_id: visitorId })
1161
+ body: JSON.stringify({ rating, visitor_id: visitorId, reason: rating === "down" ? reason ?? null : null })
1149
1162
  }
1150
1163
  );
1151
1164
  if (!res.ok) throw new Error(`${res.status}`);
1152
- options?.onFeedback?.({ conversationId, messageId, rating });
1165
+ options?.onFeedback?.({ conversationId, messageId, rating, reason });
1153
1166
  } catch (e) {
1154
1167
  console.warn("[xbnova] \uD53C\uB4DC\uBC31 \uC800\uC7A5 \uC2E4\uD328", e);
1155
1168
  apply(prevRating);
@@ -1241,7 +1254,9 @@ function useXbNovaChat(options) {
1241
1254
  return next;
1242
1255
  });
1243
1256
  const focused = getFocusedTrackEntry(trackRegistry);
1244
- const context = focused ? { region: focused.region, data: resolveFactValue(focused.data) } : void 0;
1257
+ const notes = collectContextEntries(contextRegistry);
1258
+ const screen = focused ? { region: focused.region, data: resolveFactValue(focused.data) } : {};
1259
+ const context = focused || notes.length ? { ...screen, ...notes.length ? { notes } : {} } : void 0;
1245
1260
  try {
1246
1261
  await streamChat(
1247
1262
  apiBaseUrl,
@@ -1251,7 +1266,8 @@ function useXbNovaChat(options) {
1251
1266
  message: trimmed,
1252
1267
  conversation_id: onboarding?.conversation_id ?? null,
1253
1268
  context,
1254
- nudge_node_id: nudgeNodeId ?? null
1269
+ nudge_node_id: nudgeNodeId ?? null,
1270
+ opening_message: messages.length === 0 ? (options?.openingMessage ?? onboarding?.greeting) || null : null
1255
1271
  },
1256
1272
  { onPart: replaceLast, onMessageId: setLastAssistantId, onError: setError }
1257
1273
  );
@@ -1550,6 +1566,7 @@ function XbNovaChat({
1550
1566
  onFeedback,
1551
1567
  botId: activeBotId ?? botId,
1552
1568
  nudgeNodeId: activeNudgeNodeId,
1569
+ openingMessage: openingLine ?? void 0,
1553
1570
  onDismissSheet: () => setOpen(false)
1554
1571
  });
1555
1572
  const effectiveBotId = activeBotId ?? botId;
@@ -1776,38 +1793,41 @@ function XbNovaChat({
1776
1793
  /* @__PURE__ */ jsxs3("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [
1777
1794
  showDisclosure ? /* @__PURE__ */ jsx7("div", { className: "rounded-lg bg-neutral-50 px-3 py-2 text-[11px] text-neutral-400 dark:bg-neutral-800", children: "\uC9C0\uAE08 \uBCF4\uACE0 \uACC4\uC2E0 \uD654\uBA74\uACFC \uB9E4\uBB3C \uC815\uBCF4\uB97C \uAE30\uC900\uC73C\uB85C \uC548\uB0B4\uD574 \uB4DC\uB824\uC694." }) : null,
1778
1795
  firstBubble ? /* @__PURE__ */ jsx7(Bubble, { role: "assistant", children: /* @__PURE__ */ jsx7(AssistantMarkdown, { text: firstBubble }) }) : null,
1779
- messages.map((message, i) => /* @__PURE__ */ jsx7("div", { children: message.role === "user" ? /* @__PURE__ */ jsx7(Bubble, { role: "user", children: message.parts.map((p) => p.kind === "text" ? p.text : "").join("") }) : /* @__PURE__ */ jsxs3("div", { className: "space-y-1", children: [
1780
- message.parts.map((part, j) => {
1781
- if (part.kind === "text") {
1782
- return /* @__PURE__ */ jsx7(Bubble, { role: "assistant", children: /* @__PURE__ */ jsx7(AssistantMarkdown, { text: part.text }) }, j);
1783
- }
1784
- if (clientActionHandlers.has(part.toolName)) {
1785
- const result = clientResults[part.toolCallId];
1786
- if (result) {
1787
- return /* @__PURE__ */ jsx7(
1788
- ToolView,
1789
- {
1790
- part: { ...part, state: "output", view: result.view, output: result.output },
1791
- viewRenderers,
1792
- onVerdictAction
1793
- },
1794
- j
1795
- );
1796
- }
1797
- const spec = clientActionHandlers.get(part.toolName);
1798
- return spec?.render ? /* @__PURE__ */ jsx7("div", { children: spec.render(part.input) }, j) : null;
1799
- }
1800
- return /* @__PURE__ */ jsx7(ToolView, { part, viewRenderers, onVerdictAction }, j);
1801
- }),
1802
- !hasVisiblePart(message.parts, clientActionHandlers, clientResults) && busy ? /* @__PURE__ */ jsx7(Bubble, { role: "assistant", children: /* @__PURE__ */ jsx7("span", { className: "text-neutral-400", children: "\uC0DD\uAC01\uD558\uB294 \uC911\u2026" }) }) : null,
1803
- feedbackEnabled && !busy && message.id && message.parts.some((p) => p.kind === "text" && p.text.trim()) ? /* @__PURE__ */ jsx7(
1804
- FeedbackButtons,
1805
- {
1806
- rating: feedback[message.id] ?? null,
1807
- onChange: (r) => void setFeedback(message.id, r)
1808
- }
1809
- ) : null
1810
- ] }) }, i)),
1796
+ messages.map((message, i) => /* @__PURE__ */ jsx7("div", { children: message.role === "user" ? /* @__PURE__ */ jsx7(Bubble, { role: "user", children: message.parts.map((p) => p.kind === "text" ? p.text : "").join("") }) : /* @__PURE__ */ jsxs3(
1797
+ AnswerActionsFrame,
1798
+ {
1799
+ enabled: feedbackEnabled && !busy && !!message.id && message.parts.some((p) => p.kind === "text" && p.text.trim()),
1800
+ text: message.parts.filter((p) => p.kind === "text").map((p) => p.kind === "text" ? p.text : "").join("\n\n"),
1801
+ rating: message.id ? feedback[message.id] ?? null : null,
1802
+ isLast: i === messages.length - 1,
1803
+ onRate: (r, reason) => void setFeedback(message.id, r, reason),
1804
+ children: [
1805
+ message.parts.map((part, j) => {
1806
+ if (part.kind === "text") {
1807
+ return /* @__PURE__ */ jsx7(Bubble, { role: "assistant", children: /* @__PURE__ */ jsx7(AssistantMarkdown, { text: part.text }) }, j);
1808
+ }
1809
+ if (clientActionHandlers.has(part.toolName)) {
1810
+ const result = clientResults[part.toolCallId];
1811
+ if (result) {
1812
+ return /* @__PURE__ */ jsx7(
1813
+ ToolView,
1814
+ {
1815
+ part: { ...part, state: "output", view: result.view, output: result.output },
1816
+ viewRenderers,
1817
+ onVerdictAction
1818
+ },
1819
+ j
1820
+ );
1821
+ }
1822
+ const spec = clientActionHandlers.get(part.toolName);
1823
+ return spec?.render ? /* @__PURE__ */ jsx7("div", { children: spec.render(part.input) }, j) : null;
1824
+ }
1825
+ return /* @__PURE__ */ jsx7(ToolView, { part, viewRenderers, onVerdictAction }, j);
1826
+ }),
1827
+ !hasVisiblePart(message.parts, clientActionHandlers, clientResults) && busy ? /* @__PURE__ */ jsx7(Bubble, { role: "assistant", children: /* @__PURE__ */ jsx7("span", { className: "text-neutral-400", children: "\uC0DD\uAC01\uD558\uB294 \uC911\u2026" }) }) : null
1828
+ ]
1829
+ }
1830
+ ) }, i)),
1811
1831
  error ? /* @__PURE__ */ jsx7("div", { className: "rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950 dark:text-red-300", children: error }) : null,
1812
1832
  /* @__PURE__ */ jsx7("div", { ref: bottomRef })
1813
1833
  ] }),
@@ -1910,44 +1930,193 @@ function Avatar({
1910
1930
  }
1911
1931
  var THUMB_UP_PATH = "M7 10v12M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z";
1912
1932
  var THUMB_DOWN_PATH = "M17 14V2M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z";
1913
- function FeedbackButtons({
1933
+ var COPY_PATH = "M9 9h11v11H9zM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1";
1934
+ var CHECK_PATH = "m5 12 5 5L20 7";
1935
+ var FEEDBACK_REASONS = [
1936
+ { code: "inaccurate", label: "\uC815\uD655\uD558\uC9C0 \uC54A\uC544\uC694" },
1937
+ { code: "irrelevant", label: "\uC9C8\uBB38\uACFC \uB2EC\uB77C\uC694" },
1938
+ { code: "too_long", label: "\uB108\uBB34 \uAE38\uC5B4\uC694" },
1939
+ { code: "unclear", label: "\uC774\uD574\uD558\uAE30 \uC5B4\uB824\uC6CC\uC694" }
1940
+ ];
1941
+ var LONG_PRESS_MS = 420;
1942
+ function ActionIcon({ path, filled }) {
1943
+ return /* @__PURE__ */ jsx7(
1944
+ "svg",
1945
+ {
1946
+ width: "16",
1947
+ height: "16",
1948
+ viewBox: "0 0 24 24",
1949
+ fill: filled ? "currentColor" : "none",
1950
+ stroke: "currentColor",
1951
+ strokeWidth: "2",
1952
+ strokeLinecap: "round",
1953
+ strokeLinejoin: "round",
1954
+ "aria-hidden": "true",
1955
+ children: /* @__PURE__ */ jsx7("path", { d: path })
1956
+ }
1957
+ );
1958
+ }
1959
+ async function copyText(text) {
1960
+ try {
1961
+ await navigator.clipboard.writeText(text);
1962
+ return true;
1963
+ } catch {
1964
+ try {
1965
+ const ta = document.createElement("textarea");
1966
+ ta.value = text;
1967
+ ta.style.position = "fixed";
1968
+ ta.style.opacity = "0";
1969
+ document.body.appendChild(ta);
1970
+ ta.select();
1971
+ const ok = document.execCommand("copy");
1972
+ ta.remove();
1973
+ return ok;
1974
+ } catch {
1975
+ return false;
1976
+ }
1977
+ }
1978
+ }
1979
+ function AnswerActions({
1980
+ text,
1914
1981
  rating,
1915
- onChange
1982
+ isLast,
1983
+ onRate,
1984
+ children
1916
1985
  }) {
1917
- const item = (kind, label, path) => {
1918
- const selected = rating === kind;
1919
- const color = selected ? kind === "up" ? "var(--xbnova-accent-1, #4f46e5)" : "#dc2626" : void 0;
1920
- return /* @__PURE__ */ jsx7(
1921
- "button",
1922
- {
1923
- type: "button",
1924
- "aria-label": label,
1925
- "aria-pressed": selected,
1926
- onClick: () => onChange(selected ? null : kind),
1927
- className: "flex h-6 w-6 items-center justify-center rounded-md text-neutral-400 transition-colors hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300",
1928
- style: color ? { color } : void 0,
1929
- children: /* @__PURE__ */ jsx7(
1930
- "svg",
1986
+ const rootRef = useRef8(null);
1987
+ const timerRef = useRef8(null);
1988
+ const [menuOpen, setMenuOpen] = useState4(false);
1989
+ const [reasonOpen, setReasonOpen] = useState4(false);
1990
+ const [copied, setCopied] = useState4(false);
1991
+ const [entered, setEntered] = useState4(false);
1992
+ const clearTimer = () => {
1993
+ if (timerRef.current) clearTimeout(timerRef.current);
1994
+ timerRef.current = null;
1995
+ };
1996
+ useEffect8(() => clearTimer, []);
1997
+ useEffect8(() => {
1998
+ if (!menuOpen) {
1999
+ setEntered(false);
2000
+ return;
2001
+ }
2002
+ const raf = requestAnimationFrame(() => setEntered(true));
2003
+ const onDown = (e) => {
2004
+ if (!e.composedPath().includes(rootRef.current)) setMenuOpen(false);
2005
+ };
2006
+ document.addEventListener("pointerdown", onDown, true);
2007
+ return () => {
2008
+ cancelAnimationFrame(raf);
2009
+ document.removeEventListener("pointerdown", onDown, true);
2010
+ };
2011
+ }, [menuOpen]);
2012
+ const startPress = (e) => {
2013
+ if (e.pointerType === "mouse" || isLast) return;
2014
+ clearTimer();
2015
+ timerRef.current = setTimeout(() => {
2016
+ setMenuOpen(true);
2017
+ if (typeof navigator.vibrate === "function") navigator.vibrate(10);
2018
+ }, LONG_PRESS_MS);
2019
+ };
2020
+ const handleCopy = async () => {
2021
+ if (await copyText(text)) {
2022
+ setCopied(true);
2023
+ setTimeout(() => setCopied(false), 1400);
2024
+ }
2025
+ };
2026
+ const handleRate = (kind) => {
2027
+ if (rating === kind) {
2028
+ onRate(null);
2029
+ setReasonOpen(false);
2030
+ return;
2031
+ }
2032
+ onRate(kind);
2033
+ setReasonOpen(kind === "down");
2034
+ if (kind === "up") setMenuOpen(false);
2035
+ };
2036
+ const barVisible = isLast || menuOpen;
2037
+ const floating = menuOpen && !isLast;
2038
+ const btn = (label, onClick, selected, color, icon) => /* @__PURE__ */ jsxs3(
2039
+ "button",
2040
+ {
2041
+ type: "button",
2042
+ "aria-label": label,
2043
+ "aria-pressed": selected,
2044
+ onClick,
2045
+ className: `flex items-center justify-center gap-1 rounded-full text-neutral-400 transition-colors hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 ${floating ? "h-9 px-3 text-xs font-semibold" : "h-7 w-7"}`,
2046
+ style: color ? { color } : void 0,
2047
+ children: [
2048
+ icon,
2049
+ floating ? /* @__PURE__ */ jsx7("span", { children: label }) : null
2050
+ ]
2051
+ }
2052
+ );
2053
+ return /* @__PURE__ */ jsxs3(
2054
+ "div",
2055
+ {
2056
+ ref: rootRef,
2057
+ className: "group space-y-1",
2058
+ onPointerDown: startPress,
2059
+ onPointerUp: clearTimer,
2060
+ onPointerCancel: clearTimer,
2061
+ onPointerLeave: clearTimer,
2062
+ onPointerMove: (e) => {
2063
+ if (Math.abs(e.movementX) + Math.abs(e.movementY) > 6) clearTimer();
2064
+ },
2065
+ onContextMenu: (e) => {
2066
+ if (!isLast && e.nativeEvent.pointerType !== "mouse") e.preventDefault();
2067
+ },
2068
+ children: [
2069
+ children,
2070
+ /* @__PURE__ */ jsx7(
2071
+ "div",
1931
2072
  {
1932
- width: "16",
1933
- height: "16",
1934
- viewBox: "0 0 24 24",
1935
- fill: selected ? "currentColor" : "none",
1936
- stroke: "currentColor",
1937
- strokeWidth: "2",
1938
- strokeLinecap: "round",
1939
- strokeLinejoin: "round",
1940
- "aria-hidden": "true",
1941
- children: /* @__PURE__ */ jsx7("path", { d: path })
2073
+ className: `flex pl-1 ${barVisible ? "" : "hidden md:group-hover:flex"}`,
2074
+ style: { transition: "opacity 140ms, transform 140ms", opacity: floating && !entered ? 0 : 1, transform: floating && !entered ? "scale(0.94)" : "none" },
2075
+ children: /* @__PURE__ */ jsxs3(
2076
+ "div",
2077
+ {
2078
+ className: `flex items-center ${floating ? "gap-0.5 rounded-full border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800" : "gap-0.5"}`,
2079
+ children: [
2080
+ btn(copied ? "\uBCF5\uC0AC\uB428" : "\uBCF5\uC0AC", handleCopy, false, copied ? "var(--xbnova-accent-1, #4f46e5)" : void 0, /* @__PURE__ */ jsx7(ActionIcon, { path: copied ? CHECK_PATH : COPY_PATH })),
2081
+ btn(
2082
+ "\uC88B\uC544\uC694",
2083
+ () => handleRate("up"),
2084
+ rating === "up",
2085
+ rating === "up" ? "var(--xbnova-accent-1, #4f46e5)" : void 0,
2086
+ /* @__PURE__ */ jsx7(ActionIcon, { path: THUMB_UP_PATH, filled: rating === "up" })
2087
+ ),
2088
+ btn(
2089
+ "\uBCC4\uB85C\uC608\uC694",
2090
+ () => handleRate("down"),
2091
+ rating === "down",
2092
+ rating === "down" ? "#dc2626" : void 0,
2093
+ /* @__PURE__ */ jsx7(ActionIcon, { path: THUMB_DOWN_PATH, filled: rating === "down" })
2094
+ )
2095
+ ]
2096
+ }
2097
+ )
1942
2098
  }
1943
- )
1944
- }
1945
- );
1946
- };
1947
- return /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-0.5 pl-1", children: [
1948
- item("up", "\uB3C4\uC6C0\uC774 \uB410\uC5B4\uC694", THUMB_UP_PATH),
1949
- item("down", "\uB3C4\uC6C0\uC774 \uC548 \uB410\uC5B4\uC694", THUMB_DOWN_PATH)
1950
- ] });
2099
+ ),
2100
+ rating === "down" && reasonOpen ? /* @__PURE__ */ jsxs3("div", { className: "flex flex-wrap items-center gap-1.5 pl-1 pt-0.5", children: [
2101
+ /* @__PURE__ */ jsx7("span", { className: "text-[11px] text-neutral-400", children: "\uC5B4\uB5A4 \uC810\uC774 \uC544\uC26C\uC6E0\uB098\uC694?" }),
2102
+ FEEDBACK_REASONS.map((r) => /* @__PURE__ */ jsx7(
2103
+ "button",
2104
+ {
2105
+ type: "button",
2106
+ onClick: () => {
2107
+ onRate("down", r.code);
2108
+ setReasonOpen(false);
2109
+ setMenuOpen(false);
2110
+ },
2111
+ className: "xbnova-chip rounded-full border px-2.5 py-1 text-[11px] font-semibold transition",
2112
+ children: r.label
2113
+ },
2114
+ r.code
2115
+ ))
2116
+ ] }) : null
2117
+ ]
2118
+ }
2119
+ );
1951
2120
  }
1952
2121
  function Bubble({ role, children }) {
1953
2122
  const mine = role === "user";
@@ -1962,9 +2131,17 @@ function Bubble({ role, children }) {
1962
2131
  function AssistantMarkdown({ text }) {
1963
2132
  return /* @__PURE__ */ jsx7("div", { className: "space-y-1.5 text-sm leading-relaxed [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5 [&_li]:pl-0.5 [&_strong]:font-semibold [&_a]:underline [&_code]:rounded [&_code]:bg-black/10 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[0.85em] [&_p]:mb-1.5 last:[&_p]:mb-0", children: /* @__PURE__ */ jsx7(ReactMarkdown, { remarkPlugins: [remarkGfm, remarkBreaks], children: text }) });
1964
2133
  }
2134
+ function AnswerActionsFrame({
2135
+ enabled,
2136
+ children,
2137
+ ...rest
2138
+ }) {
2139
+ if (!enabled) return /* @__PURE__ */ jsx7("div", { className: "space-y-1", children });
2140
+ return /* @__PURE__ */ jsx7(AnswerActions, { ...rest, children });
2141
+ }
1965
2142
 
1966
2143
  // src/facts.ts
1967
- import { useEffect as useEffect9, useRef as useRef9 } from "react";
2144
+ import { useEffect as useEffect9, useId, useRef as useRef9 } from "react";
1968
2145
  function useXbNovaFact(key, value) {
1969
2146
  const { factsRegistry } = useXbNovaInternal();
1970
2147
  useEffect9(() => {
@@ -1993,6 +2170,18 @@ function useXbNovaFacts(values) {
1993
2170
  };
1994
2171
  }, [factsRegistry]);
1995
2172
  }
2173
+ function useXbNovaContext(entries) {
2174
+ const { contextRegistry } = useXbNovaInternal();
2175
+ const id = useId();
2176
+ useEffect9(() => {
2177
+ contextRegistry.set(id, entries);
2178
+ });
2179
+ useEffect9(() => {
2180
+ return () => {
2181
+ contextRegistry.delete(id);
2182
+ };
2183
+ }, [id, contextRegistry]);
2184
+ }
1996
2185
  function useXbNovaVisitor() {
1997
2186
  const { visitorId } = useXbNovaInternal();
1998
2187
  return visitorId;
@@ -2013,6 +2202,7 @@ export {
2013
2202
  XbNovaTrack,
2014
2203
  trackXbNovaEvent,
2015
2204
  useXbNovaChat,
2205
+ useXbNovaContext,
2016
2206
  useXbNovaFact,
2017
2207
  useXbNovaFacts,
2018
2208
  useXbNovaSettings,