@xbrain-swe/nova-chat-widget 0.1.4 → 0.1.7

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
@@ -1,5 +1,44 @@
1
1
  import * as react from 'react';
2
2
 
3
+ /**
4
+ * AI SDK UI Message Stream v1 파서. `nova/web/lib/chat-stream.ts`에서 그대로 옮겼다 — 이미
5
+ * 콘솔에서 검증된 로직이라 새로 설계하지 않는다.
6
+ *
7
+ * `ai` 패키지에 의존하지 않는다 — 고객사 번들에 peer 버전 충돌을 만들지 않기 위해서다.
8
+ */
9
+ type ViewKind = "product_list" | "product_detail" | "metric" | "choices" | "form" | "verdict";
10
+ type ToolPart = {
11
+ kind: "tool";
12
+ toolCallId: string;
13
+ toolName: string;
14
+ state: "input" | "output" | "error";
15
+ input?: unknown;
16
+ output?: unknown;
17
+ /** 위젯은 tool 이름이 아니라 이 값으로 렌더러를 고른다. */
18
+ view?: ViewKind | null;
19
+ };
20
+ type TextPart = {
21
+ kind: "text";
22
+ id: string;
23
+ text: string;
24
+ };
25
+ type MessagePart = TextPart | ToolPart;
26
+ type ChatMessage = {
27
+ role: "user" | "assistant";
28
+ parts: MessagePart[];
29
+ };
30
+
31
+ /** `awaitResult` 핸들러가 구조화된 결과를 돌려줄 때의 모양. `text`는 모델이 읽는 결과(서버로
32
+ * 전송)고, `view`+`output`은 채팅 말풍선에 직접 그릴 정규화된 데이터다 — 서버에는 가지 않는다.
33
+ * 렌더러는 tool 이름이 아니라 `view`로 고르므로(`views.tsx`) 고객사가 UI를 만들 필요가 없다.
34
+ * 예: 상품 검색 결과는 `view:"product_list"`, `output:{ items: ProductItem[], total }`.
35
+ * MCP 서버를 쓸 수 없는 환경에서 MCP tool 결과의 `_meta` view와 같은 효과를 내는 대체 경로다.
36
+ * 대화를 새로고침하면 이 그림은 사라진다(서버에는 `text`만 남는다). */
37
+ type XbNovaClientActionResult = {
38
+ text: string;
39
+ view?: ViewKind;
40
+ output?: unknown;
41
+ };
3
42
  type XbNovaClientAction = {
4
43
  /** 모델이 "언제 이 tool을 부를지" 판단하는 근거 — MCP tool description과 같은 역할. */
5
44
  description: string;
@@ -15,11 +54,15 @@ type XbNovaClientAction = {
15
54
  * interrupt/resume과 같은 패턴). false(기본)면 `handler`는 그냥 실행되고 끝 —
16
55
  * 반환값은 안 쓴다. */
17
56
  awaitResult?: boolean;
57
+ /** `awaitResult:true`일 때 핸들러 응답을 기다리는 최대 시간(ms, 기본 15000). 넘으면 SDK가
58
+ * "오류: 응답 시간이 초과되었습니다."를 tool 결과로 서버에 보내 모델이 대화를 이어가게
59
+ * 한다 — 이후 핸들러가 뒤늦게 resolve돼도 그 값은 무시된다. fire-and-forget에는 적용 안 된다. */
60
+ timeoutMs?: number;
18
61
  /** 실제로 실행할 동작 — 채팅에는 아무것도 안 그리고 이 함수만 호출된다. `payload`는 이
19
62
  * tool 호출의 input을 그대로 되돌려준다. `awaitResult:true`면 반환값(또는 resolve된
20
63
  * Promise)이 진짜 tool 결과 텍스트가 된다 — 비워두면 "핸들러가 결과를 반환하지
21
64
  * 않았습니다" 에러로 처리된다. */
22
- handler: (payload: unknown) => void | string | Promise<void | string>;
65
+ handler: (payload: unknown) => void | string | XbNovaClientActionResult | Promise<void | string | XbNovaClientActionResult>;
23
66
  /** true면 이 tool이 실행되는 시점에 대화 시트를 닫는다(대화는 안 사라진다, 나중에
24
67
  * 다시 열면 이어짐). `highlight_region`처럼 "페이지의 특정 부분을 보여주는" 액션에
25
68
  * 필요하다 — 모바일에서는 시트가 화면 대부분(위 7rem 제외)을 덮어서, 시트를 안 닫으면
@@ -66,34 +109,6 @@ declare function XbNovaProvider({ apiKey, botId, appId, screen, apiBaseUrl, clie
66
109
  children: React.ReactNode;
67
110
  }): react.JSX.Element;
68
111
 
69
- /**
70
- * AI SDK UI Message Stream v1 파서. `nova/web/lib/chat-stream.ts`에서 그대로 옮겼다 — 이미
71
- * 콘솔에서 검증된 로직이라 새로 설계하지 않는다.
72
- *
73
- * `ai` 패키지에 의존하지 않는다 — 고객사 번들에 peer 버전 충돌을 만들지 않기 위해서다.
74
- */
75
- type ViewKind = "product_list" | "product_detail" | "metric" | "choices" | "form" | "verdict";
76
- type ToolPart = {
77
- kind: "tool";
78
- toolCallId: string;
79
- toolName: string;
80
- state: "input" | "output" | "error";
81
- input?: unknown;
82
- output?: unknown;
83
- /** 위젯은 tool 이름이 아니라 이 값으로 렌더러를 고른다. */
84
- view?: ViewKind | null;
85
- };
86
- type TextPart = {
87
- kind: "text";
88
- id: string;
89
- text: string;
90
- };
91
- type MessagePart = TextPart | ToolPart;
92
- type ChatMessage = {
93
- role: "user" | "assistant";
94
- parts: MessagePart[];
95
- };
96
-
97
112
  type ProductItem = {
98
113
  id: string;
99
114
  title: string;
@@ -133,7 +148,7 @@ type ViewRenderer = (props: {
133
148
  }) => React.ReactNode;
134
149
  type ViewRendererMap = Partial<Record<ViewKind, ViewRenderer>>;
135
150
 
136
- declare function XbNovaChat({ viewRenderers, botId, barStyle, panelStyle, backdropStyle, defaultOpen, idleText, onVerdictAction, }: {
151
+ declare function XbNovaChat({ viewRenderers, botId, barStyle, panelStyle, backdropStyle, defaultOpen, idleText, avatarSize, onVerdictAction, }: {
137
152
  /** 특정 view만 자체 컴포넌트로 덮어쓰고 싶을 때 — 지정 안 한 view는 기본 렌더러를 쓴다. */
138
153
  viewRenderers?: ViewRendererMap;
139
154
  /** Provider의 기본 봇 대신 다른 봇과 고정적으로 대화하고 싶을 때. 힌트를 눌러 열면
@@ -152,17 +167,26 @@ declare function XbNovaChat({ viewRenderers, botId, barStyle, panelStyle, backdr
152
167
  defaultOpen?: boolean;
153
168
  /** 기본(idle) 상태 바 문구. */
154
169
  idleText?: string;
170
+ /** 바(AI 버튼)의 동그란 아바타 지름(px), 기본 28. 바의 접힌 원은 52px이라 44~48이면 원을 거의 채운다.
171
+ * 채팅 헤더의 아바타는 이 값에 맞춰 24~36px로 따라 커진다. */
172
+ avatarSize?: number;
155
173
  /** 판단 카드의 행동 버튼을 눌렀을 때 — 실제 동작(상담신청 폼 열기, 섹션 스크롤 등)은
156
174
  * 소비자가 정한다. SDK는 버튼에 적힌 `action` 문자열을 그대로 넘겨줄 뿐 뜻을 모른다. */
157
175
  onVerdictAction?: (action: string) => void;
158
176
  }): react.JSX.Element | null;
159
177
 
178
+ type ClientResults = Record<string, {
179
+ view: ViewKind;
180
+ output: unknown;
181
+ }>;
160
182
  type XbNovaOnboarding = {
161
183
  conversation_id: string;
162
184
  greeting: string;
163
185
  quick_replies: string[];
164
186
  branding: {
165
187
  title?: string;
188
+ logo_url?: string;
189
+ avatar_text?: string;
166
190
  };
167
191
  };
168
192
  type UseXbNovaChatResult = {
@@ -171,6 +195,8 @@ type UseXbNovaChatResult = {
171
195
  busy: boolean;
172
196
  error: string | null;
173
197
  send: (text: string) => Promise<void>;
198
+ /** client action 핸들러가 돌려준 정규화 결과(tool_call_id별). 채팅 화면이 view 렌더러로 그린다. */
199
+ clientResults: ClientResults;
174
200
  };
175
201
  /** `botId`를 넘기면 Provider의 기본 봇 대신 그 봇으로 새 대화를 연다 — 넛지 handoff가 쓴다.
176
202
  * `nudgeNodeId`를 넘기면 이 대화가 그 넛지에서 시작됐다고 서버에 표시한다 — 넛지 전환
@@ -226,4 +252,4 @@ declare function useXbNovaSettings(): {
226
252
  showBar: () => void;
227
253
  };
228
254
 
229
- 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 XbNovaClientActions, XbNovaFact, XbNovaFacts, type XbNovaOnboarding, XbNovaProvider, type XbNovaTheme, XbNovaTrack, trackXbNovaEvent, useXbNovaChat, useXbNovaFact, useXbNovaFacts, useXbNovaSettings, useXbNovaTrack, useXbNovaVisitor };
255
+ 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 XbNovaOnboarding, XbNovaProvider, type XbNovaTheme, XbNovaTrack, trackXbNovaEvent, useXbNovaChat, useXbNovaFact, useXbNovaFacts, useXbNovaSettings, useXbNovaTrack, useXbNovaVisitor };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,44 @@
1
1
  import * as react from 'react';
2
2
 
3
+ /**
4
+ * AI SDK UI Message Stream v1 파서. `nova/web/lib/chat-stream.ts`에서 그대로 옮겼다 — 이미
5
+ * 콘솔에서 검증된 로직이라 새로 설계하지 않는다.
6
+ *
7
+ * `ai` 패키지에 의존하지 않는다 — 고객사 번들에 peer 버전 충돌을 만들지 않기 위해서다.
8
+ */
9
+ type ViewKind = "product_list" | "product_detail" | "metric" | "choices" | "form" | "verdict";
10
+ type ToolPart = {
11
+ kind: "tool";
12
+ toolCallId: string;
13
+ toolName: string;
14
+ state: "input" | "output" | "error";
15
+ input?: unknown;
16
+ output?: unknown;
17
+ /** 위젯은 tool 이름이 아니라 이 값으로 렌더러를 고른다. */
18
+ view?: ViewKind | null;
19
+ };
20
+ type TextPart = {
21
+ kind: "text";
22
+ id: string;
23
+ text: string;
24
+ };
25
+ type MessagePart = TextPart | ToolPart;
26
+ type ChatMessage = {
27
+ role: "user" | "assistant";
28
+ parts: MessagePart[];
29
+ };
30
+
31
+ /** `awaitResult` 핸들러가 구조화된 결과를 돌려줄 때의 모양. `text`는 모델이 읽는 결과(서버로
32
+ * 전송)고, `view`+`output`은 채팅 말풍선에 직접 그릴 정규화된 데이터다 — 서버에는 가지 않는다.
33
+ * 렌더러는 tool 이름이 아니라 `view`로 고르므로(`views.tsx`) 고객사가 UI를 만들 필요가 없다.
34
+ * 예: 상품 검색 결과는 `view:"product_list"`, `output:{ items: ProductItem[], total }`.
35
+ * MCP 서버를 쓸 수 없는 환경에서 MCP tool 결과의 `_meta` view와 같은 효과를 내는 대체 경로다.
36
+ * 대화를 새로고침하면 이 그림은 사라진다(서버에는 `text`만 남는다). */
37
+ type XbNovaClientActionResult = {
38
+ text: string;
39
+ view?: ViewKind;
40
+ output?: unknown;
41
+ };
3
42
  type XbNovaClientAction = {
4
43
  /** 모델이 "언제 이 tool을 부를지" 판단하는 근거 — MCP tool description과 같은 역할. */
5
44
  description: string;
@@ -15,11 +54,15 @@ type XbNovaClientAction = {
15
54
  * interrupt/resume과 같은 패턴). false(기본)면 `handler`는 그냥 실행되고 끝 —
16
55
  * 반환값은 안 쓴다. */
17
56
  awaitResult?: boolean;
57
+ /** `awaitResult:true`일 때 핸들러 응답을 기다리는 최대 시간(ms, 기본 15000). 넘으면 SDK가
58
+ * "오류: 응답 시간이 초과되었습니다."를 tool 결과로 서버에 보내 모델이 대화를 이어가게
59
+ * 한다 — 이후 핸들러가 뒤늦게 resolve돼도 그 값은 무시된다. fire-and-forget에는 적용 안 된다. */
60
+ timeoutMs?: number;
18
61
  /** 실제로 실행할 동작 — 채팅에는 아무것도 안 그리고 이 함수만 호출된다. `payload`는 이
19
62
  * tool 호출의 input을 그대로 되돌려준다. `awaitResult:true`면 반환값(또는 resolve된
20
63
  * Promise)이 진짜 tool 결과 텍스트가 된다 — 비워두면 "핸들러가 결과를 반환하지
21
64
  * 않았습니다" 에러로 처리된다. */
22
- handler: (payload: unknown) => void | string | Promise<void | string>;
65
+ handler: (payload: unknown) => void | string | XbNovaClientActionResult | Promise<void | string | XbNovaClientActionResult>;
23
66
  /** true면 이 tool이 실행되는 시점에 대화 시트를 닫는다(대화는 안 사라진다, 나중에
24
67
  * 다시 열면 이어짐). `highlight_region`처럼 "페이지의 특정 부분을 보여주는" 액션에
25
68
  * 필요하다 — 모바일에서는 시트가 화면 대부분(위 7rem 제외)을 덮어서, 시트를 안 닫으면
@@ -66,34 +109,6 @@ declare function XbNovaProvider({ apiKey, botId, appId, screen, apiBaseUrl, clie
66
109
  children: React.ReactNode;
67
110
  }): react.JSX.Element;
68
111
 
69
- /**
70
- * AI SDK UI Message Stream v1 파서. `nova/web/lib/chat-stream.ts`에서 그대로 옮겼다 — 이미
71
- * 콘솔에서 검증된 로직이라 새로 설계하지 않는다.
72
- *
73
- * `ai` 패키지에 의존하지 않는다 — 고객사 번들에 peer 버전 충돌을 만들지 않기 위해서다.
74
- */
75
- type ViewKind = "product_list" | "product_detail" | "metric" | "choices" | "form" | "verdict";
76
- type ToolPart = {
77
- kind: "tool";
78
- toolCallId: string;
79
- toolName: string;
80
- state: "input" | "output" | "error";
81
- input?: unknown;
82
- output?: unknown;
83
- /** 위젯은 tool 이름이 아니라 이 값으로 렌더러를 고른다. */
84
- view?: ViewKind | null;
85
- };
86
- type TextPart = {
87
- kind: "text";
88
- id: string;
89
- text: string;
90
- };
91
- type MessagePart = TextPart | ToolPart;
92
- type ChatMessage = {
93
- role: "user" | "assistant";
94
- parts: MessagePart[];
95
- };
96
-
97
112
  type ProductItem = {
98
113
  id: string;
99
114
  title: string;
@@ -133,7 +148,7 @@ type ViewRenderer = (props: {
133
148
  }) => React.ReactNode;
134
149
  type ViewRendererMap = Partial<Record<ViewKind, ViewRenderer>>;
135
150
 
136
- declare function XbNovaChat({ viewRenderers, botId, barStyle, panelStyle, backdropStyle, defaultOpen, idleText, onVerdictAction, }: {
151
+ declare function XbNovaChat({ viewRenderers, botId, barStyle, panelStyle, backdropStyle, defaultOpen, idleText, avatarSize, onVerdictAction, }: {
137
152
  /** 특정 view만 자체 컴포넌트로 덮어쓰고 싶을 때 — 지정 안 한 view는 기본 렌더러를 쓴다. */
138
153
  viewRenderers?: ViewRendererMap;
139
154
  /** Provider의 기본 봇 대신 다른 봇과 고정적으로 대화하고 싶을 때. 힌트를 눌러 열면
@@ -152,17 +167,26 @@ declare function XbNovaChat({ viewRenderers, botId, barStyle, panelStyle, backdr
152
167
  defaultOpen?: boolean;
153
168
  /** 기본(idle) 상태 바 문구. */
154
169
  idleText?: string;
170
+ /** 바(AI 버튼)의 동그란 아바타 지름(px), 기본 28. 바의 접힌 원은 52px이라 44~48이면 원을 거의 채운다.
171
+ * 채팅 헤더의 아바타는 이 값에 맞춰 24~36px로 따라 커진다. */
172
+ avatarSize?: number;
155
173
  /** 판단 카드의 행동 버튼을 눌렀을 때 — 실제 동작(상담신청 폼 열기, 섹션 스크롤 등)은
156
174
  * 소비자가 정한다. SDK는 버튼에 적힌 `action` 문자열을 그대로 넘겨줄 뿐 뜻을 모른다. */
157
175
  onVerdictAction?: (action: string) => void;
158
176
  }): react.JSX.Element | null;
159
177
 
178
+ type ClientResults = Record<string, {
179
+ view: ViewKind;
180
+ output: unknown;
181
+ }>;
160
182
  type XbNovaOnboarding = {
161
183
  conversation_id: string;
162
184
  greeting: string;
163
185
  quick_replies: string[];
164
186
  branding: {
165
187
  title?: string;
188
+ logo_url?: string;
189
+ avatar_text?: string;
166
190
  };
167
191
  };
168
192
  type UseXbNovaChatResult = {
@@ -171,6 +195,8 @@ type UseXbNovaChatResult = {
171
195
  busy: boolean;
172
196
  error: string | null;
173
197
  send: (text: string) => Promise<void>;
198
+ /** client action 핸들러가 돌려준 정규화 결과(tool_call_id별). 채팅 화면이 view 렌더러로 그린다. */
199
+ clientResults: ClientResults;
174
200
  };
175
201
  /** `botId`를 넘기면 Provider의 기본 봇 대신 그 봇으로 새 대화를 연다 — 넛지 handoff가 쓴다.
176
202
  * `nudgeNodeId`를 넘기면 이 대화가 그 넛지에서 시작됐다고 서버에 표시한다 — 넛지 전환
@@ -226,4 +252,4 @@ declare function useXbNovaSettings(): {
226
252
  showBar: () => void;
227
253
  };
228
254
 
229
- 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 XbNovaClientActions, XbNovaFact, XbNovaFacts, type XbNovaOnboarding, XbNovaProvider, type XbNovaTheme, XbNovaTrack, trackXbNovaEvent, useXbNovaChat, useXbNovaFact, useXbNovaFacts, useXbNovaSettings, useXbNovaTrack, useXbNovaVisitor };
255
+ 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 XbNovaOnboarding, XbNovaProvider, type XbNovaTheme, XbNovaTrack, trackXbNovaEvent, useXbNovaChat, useXbNovaFact, useXbNovaFacts, useXbNovaSettings, useXbNovaTrack, useXbNovaVisitor };
package/dist/index.js CHANGED
@@ -888,6 +888,7 @@ function XbNovaProvider({
888
888
  {
889
889
  handler: spec.handler,
890
890
  awaitResult: spec.awaitResult ?? false,
891
+ timeoutMs: spec.timeoutMs,
891
892
  dismissSheet: spec.dismissSheet ?? false,
892
893
  render: spec.render
893
894
  }
@@ -1083,6 +1084,7 @@ async function resumeToolResult(apiBaseUrl, apiKey, body, handlers) {
1083
1084
  }
1084
1085
 
1085
1086
  // src/use-chat.ts
1087
+ var DEFAULT_CLIENT_ACTION_TIMEOUT_MS = 15e3;
1086
1088
  function useXbNovaChat(options) {
1087
1089
  const { auth, trackRegistry, clientActionHandlers } = useXbNovaInternal();
1088
1090
  const botId = options?.botId ?? auth.botId;
@@ -1092,6 +1094,7 @@ function useXbNovaChat(options) {
1092
1094
  const [messages, setMessages] = useState3([]);
1093
1095
  const [busy, setBusy] = useState3(false);
1094
1096
  const [error, setError] = useState3(null);
1097
+ const [clientResults, setClientResults] = useState3({});
1095
1098
  const dispatchedClientActionsRef = useRef7(/* @__PURE__ */ new Set());
1096
1099
  const nudgeNodeIdRef = useRef7(nudgeNodeId);
1097
1100
  nudgeNodeIdRef.current = nudgeNodeId;
@@ -1099,6 +1102,7 @@ function useXbNovaChat(options) {
1099
1102
  setOnboarding(null);
1100
1103
  setMessages([]);
1101
1104
  setError(null);
1105
+ setClientResults({});
1102
1106
  dispatchedClientActionsRef.current = /* @__PURE__ */ new Set();
1103
1107
  fetch(`${apiBaseUrl}/v1/conversations`, {
1104
1108
  method: "POST",
@@ -1115,7 +1119,20 @@ function useXbNovaChat(options) {
1115
1119
  if (spec.dismissSheet) options?.onDismissSheet?.();
1116
1120
  let result;
1117
1121
  try {
1118
- result = await spec.handler(part.input);
1122
+ if (!spec.awaitResult) {
1123
+ await spec.handler(part.input);
1124
+ return;
1125
+ }
1126
+ const timeoutMs = spec.timeoutMs ?? DEFAULT_CLIENT_ACTION_TIMEOUT_MS;
1127
+ let timer;
1128
+ const timeout = new Promise((resolve) => {
1129
+ timer = setTimeout(() => resolve("\uC624\uB958: \uC751\uB2F5 \uC2DC\uAC04\uC774 \uCD08\uACFC\uB418\uC5C8\uC2B5\uB2C8\uB2E4."), timeoutMs);
1130
+ });
1131
+ try {
1132
+ result = await Promise.race([Promise.resolve(spec.handler(part.input)), timeout]);
1133
+ } finally {
1134
+ clearTimeout(timer);
1135
+ }
1119
1136
  } catch (e) {
1120
1137
  if (!spec.awaitResult) return;
1121
1138
  result = e instanceof Error ? `\uC624\uB958: ${e.message}` : "\uC624\uB958\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4.";
@@ -1123,7 +1140,15 @@ function useXbNovaChat(options) {
1123
1140
  if (!spec.awaitResult) return;
1124
1141
  const conversationId = onboarding?.conversation_id;
1125
1142
  if (!conversationId) return;
1126
- const resultText = typeof result === "string" && result.trim() ? result : "\uD578\uB4E4\uB7EC\uAC00 \uACB0\uACFC\uB97C \uBC18\uD658\uD558\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.";
1143
+ const structured = result && typeof result === "object" ? result : null;
1144
+ if (structured?.view) {
1145
+ setClientResults((prev) => ({
1146
+ ...prev,
1147
+ [part.toolCallId]: { view: structured.view, output: structured.output }
1148
+ }));
1149
+ }
1150
+ const rawText = structured ? structured.text : result;
1151
+ const resultText = typeof rawText === "string" && rawText.trim() ? rawText : "\uD578\uB4E4\uB7EC\uAC00 \uACB0\uACFC\uB97C \uBC18\uD658\uD558\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.";
1127
1152
  setBusy(true);
1128
1153
  try {
1129
1154
  await resumeToolResult(
@@ -1192,7 +1217,7 @@ function useXbNovaChat(options) {
1192
1217
  setBusy(false);
1193
1218
  }
1194
1219
  }
1195
- return { onboarding, messages, busy, error, send };
1220
+ return { onboarding, messages, busy, error, send, clientResults };
1196
1221
  }
1197
1222
 
1198
1223
  // src/views.tsx
@@ -1315,9 +1340,10 @@ function ToolView({
1315
1340
 
1316
1341
  // src/chat.tsx
1317
1342
  import { Fragment, jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
1318
- function hasVisiblePart(parts, clientActionHandlers) {
1343
+ function hasVisiblePart(parts, clientActionHandlers, clientResults) {
1319
1344
  return parts.some((part) => {
1320
1345
  if (part.kind === "text") return true;
1346
+ if (clientResults[part.toolCallId]) return true;
1321
1347
  const spec = clientActionHandlers.get(part.toolName);
1322
1348
  return spec ? Boolean(spec.render) : true;
1323
1349
  });
@@ -1450,6 +1476,7 @@ function XbNovaChat({
1450
1476
  backdropStyle,
1451
1477
  defaultOpen = false,
1452
1478
  idleText = "AI\uC5D0\uAC8C \uC774 \uCC28 \uBB3C\uC5B4\uBCF4\uAE30",
1479
+ avatarSize = 28,
1453
1480
  onVerdictAction
1454
1481
  }) {
1455
1482
  const { auth, trackRegistry, factsRegistry, visitorId, clientActionHandlers, theme } = useXbNovaInternal();
@@ -1457,6 +1484,7 @@ function XbNovaChat({
1457
1484
  "--xbnova-accent-1": theme.accentFrom,
1458
1485
  "--xbnova-accent-2": theme.accentTo
1459
1486
  };
1487
+ const headerAvatarSize = Math.min(36, Math.max(24, Math.round(avatarSize * 0.8)));
1460
1488
  const avatarGradientStyle = {
1461
1489
  background: "linear-gradient(135deg, var(--xbnova-accent-1), var(--xbnova-accent-2))"
1462
1490
  };
@@ -1472,7 +1500,7 @@ function XbNovaChat({
1472
1500
  const [input, setInput] = useState4("");
1473
1501
  const bottomRef = useRef8(null);
1474
1502
  const typedHintText = useTypewriter(hint?.text ?? "", hint?.triggerNodeId ?? "");
1475
- const { onboarding, messages, busy, error, send } = useXbNovaChat({
1503
+ const { onboarding, messages, busy, error, send, clientResults } = useXbNovaChat({
1476
1504
  botId: activeBotId ?? botId,
1477
1505
  nudgeNodeId: activeNudgeNodeId,
1478
1506
  onDismissSheet: () => setOpen(false)
@@ -1602,11 +1630,12 @@ function XbNovaChat({
1602
1630
  className: "flex shrink-0 items-center justify-center",
1603
1631
  style: { width: collapsedSize, height: collapsedSize },
1604
1632
  children: /* @__PURE__ */ jsx7(
1605
- "span",
1633
+ Avatar,
1606
1634
  {
1607
- className: "flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-bold text-white shadow-md",
1608
- style: avatarGradientStyle,
1609
- children: "AI"
1635
+ branding: onboarding?.branding,
1636
+ size: avatarSize,
1637
+ className: "shadow-md",
1638
+ gradient: avatarGradientStyle
1610
1639
  }
1611
1640
  )
1612
1641
  }
@@ -1650,11 +1679,12 @@ function XbNovaChat({
1650
1679
  children: /* @__PURE__ */ jsxs3("div", { className: "flex h-full w-full flex-col rounded-t-[20px] bg-white shadow-2xl sm:rounded-2xl sm:border sm:border-neutral-200 dark:bg-neutral-900 sm:dark:border-neutral-700", children: [
1651
1680
  /* @__PURE__ */ jsxs3("header", { className: "flex items-center gap-2 border-b border-neutral-200 px-3.5 py-2.5 dark:border-neutral-700", children: [
1652
1681
  /* @__PURE__ */ jsx7(
1653
- "span",
1682
+ Avatar,
1654
1683
  {
1655
- className: "flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-bold text-white",
1656
- style: avatarGradientStyle,
1657
- children: "AI"
1684
+ branding: onboarding?.branding,
1685
+ size: headerAvatarSize,
1686
+ className: "shrink-0",
1687
+ gradient: avatarGradientStyle
1658
1688
  }
1659
1689
  ),
1660
1690
  /* @__PURE__ */ jsxs3("div", { className: "min-w-0 flex-1", children: [
@@ -1681,12 +1711,24 @@ function XbNovaChat({
1681
1711
  return /* @__PURE__ */ jsx7(Bubble, { role: "assistant", children: /* @__PURE__ */ jsx7(AssistantMarkdown, { text: part.text }) }, j);
1682
1712
  }
1683
1713
  if (clientActionHandlers.has(part.toolName)) {
1714
+ const result = clientResults[part.toolCallId];
1715
+ if (result) {
1716
+ return /* @__PURE__ */ jsx7(
1717
+ ToolView,
1718
+ {
1719
+ part: { ...part, state: "output", view: result.view, output: result.output },
1720
+ viewRenderers,
1721
+ onVerdictAction
1722
+ },
1723
+ j
1724
+ );
1725
+ }
1684
1726
  const spec = clientActionHandlers.get(part.toolName);
1685
1727
  return spec?.render ? /* @__PURE__ */ jsx7("div", { children: spec.render(part.input) }, j) : null;
1686
1728
  }
1687
1729
  return /* @__PURE__ */ jsx7(ToolView, { part, viewRenderers, onVerdictAction }, j);
1688
1730
  }),
1689
- !hasVisiblePart(message.parts, clientActionHandlers) && busy ? /* @__PURE__ */ jsx7(Bubble, { role: "assistant", children: /* @__PURE__ */ jsx7("span", { className: "text-neutral-400", children: "\uC0DD\uAC01\uD558\uB294 \uC911\u2026" }) }) : null
1731
+ !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
1690
1732
  ] }) }, i)),
1691
1733
  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,
1692
1734
  /* @__PURE__ */ jsx7("div", { ref: bottomRef })
@@ -1757,6 +1799,37 @@ function XbNovaChat({
1757
1799
  )
1758
1800
  ] });
1759
1801
  }
1802
+ function Avatar({
1803
+ branding,
1804
+ size,
1805
+ className = "",
1806
+ gradient
1807
+ }) {
1808
+ const box = { width: size, height: size };
1809
+ const logoUrl = branding?.logo_url?.trim() ?? "";
1810
+ const [failedUrl, setFailedUrl] = useState4("");
1811
+ if (logoUrl && failedUrl !== logoUrl) {
1812
+ return /* @__PURE__ */ jsx7(
1813
+ "img",
1814
+ {
1815
+ src: logoUrl,
1816
+ alt: "",
1817
+ onError: () => setFailedUrl(logoUrl),
1818
+ style: box,
1819
+ className: `${className} flex-none rounded-full bg-white object-cover`
1820
+ }
1821
+ );
1822
+ }
1823
+ const text = branding?.avatar_text?.trim() || "AI";
1824
+ return /* @__PURE__ */ jsx7(
1825
+ "span",
1826
+ {
1827
+ className: `${className} flex flex-none items-center justify-center rounded-full font-bold text-white`,
1828
+ style: { ...gradient, ...box, fontSize: Math.max(10, Math.round(size * 0.4)) },
1829
+ children: text
1830
+ }
1831
+ );
1832
+ }
1760
1833
  function Bubble({ role, children }) {
1761
1834
  const mine = role === "user";
1762
1835
  return /* @__PURE__ */ jsx7("div", { className: mine ? "flex justify-end" : "flex justify-start", children: /* @__PURE__ */ jsx7(