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

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;
@@ -157,12 +172,18 @@ declare function XbNovaChat({ viewRenderers, botId, barStyle, panelStyle, backdr
157
172
  onVerdictAction?: (action: string) => void;
158
173
  }): react.JSX.Element | null;
159
174
 
175
+ type ClientResults = Record<string, {
176
+ view: ViewKind;
177
+ output: unknown;
178
+ }>;
160
179
  type XbNovaOnboarding = {
161
180
  conversation_id: string;
162
181
  greeting: string;
163
182
  quick_replies: string[];
164
183
  branding: {
165
184
  title?: string;
185
+ logo_url?: string;
186
+ avatar_text?: string;
166
187
  };
167
188
  };
168
189
  type UseXbNovaChatResult = {
@@ -171,6 +192,8 @@ type UseXbNovaChatResult = {
171
192
  busy: boolean;
172
193
  error: string | null;
173
194
  send: (text: string) => Promise<void>;
195
+ /** client action 핸들러가 돌려준 정규화 결과(tool_call_id별). 채팅 화면이 view 렌더러로 그린다. */
196
+ clientResults: ClientResults;
174
197
  };
175
198
  /** `botId`를 넘기면 Provider의 기본 봇 대신 그 봇으로 새 대화를 연다 — 넛지 handoff가 쓴다.
176
199
  * `nudgeNodeId`를 넘기면 이 대화가 그 넛지에서 시작됐다고 서버에 표시한다 — 넛지 전환
@@ -226,4 +249,4 @@ declare function useXbNovaSettings(): {
226
249
  showBar: () => void;
227
250
  };
228
251
 
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 };
252
+ 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;
@@ -157,12 +172,18 @@ declare function XbNovaChat({ viewRenderers, botId, barStyle, panelStyle, backdr
157
172
  onVerdictAction?: (action: string) => void;
158
173
  }): react.JSX.Element | null;
159
174
 
175
+ type ClientResults = Record<string, {
176
+ view: ViewKind;
177
+ output: unknown;
178
+ }>;
160
179
  type XbNovaOnboarding = {
161
180
  conversation_id: string;
162
181
  greeting: string;
163
182
  quick_replies: string[];
164
183
  branding: {
165
184
  title?: string;
185
+ logo_url?: string;
186
+ avatar_text?: string;
166
187
  };
167
188
  };
168
189
  type UseXbNovaChatResult = {
@@ -171,6 +192,8 @@ type UseXbNovaChatResult = {
171
192
  busy: boolean;
172
193
  error: string | null;
173
194
  send: (text: string) => Promise<void>;
195
+ /** client action 핸들러가 돌려준 정규화 결과(tool_call_id별). 채팅 화면이 view 렌더러로 그린다. */
196
+ clientResults: ClientResults;
174
197
  };
175
198
  /** `botId`를 넘기면 Provider의 기본 봇 대신 그 봇으로 새 대화를 연다 — 넛지 handoff가 쓴다.
176
199
  * `nudgeNodeId`를 넘기면 이 대화가 그 넛지에서 시작됐다고 서버에 표시한다 — 넛지 전환
@@ -226,4 +249,4 @@ declare function useXbNovaSettings(): {
226
249
  showBar: () => void;
227
250
  };
228
251
 
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 };
252
+ 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
  });
@@ -1472,7 +1498,7 @@ function XbNovaChat({
1472
1498
  const [input, setInput] = useState4("");
1473
1499
  const bottomRef = useRef8(null);
1474
1500
  const typedHintText = useTypewriter(hint?.text ?? "", hint?.triggerNodeId ?? "");
1475
- const { onboarding, messages, busy, error, send } = useXbNovaChat({
1501
+ const { onboarding, messages, busy, error, send, clientResults } = useXbNovaChat({
1476
1502
  botId: activeBotId ?? botId,
1477
1503
  nudgeNodeId: activeNudgeNodeId,
1478
1504
  onDismissSheet: () => setOpen(false)
@@ -1602,11 +1628,11 @@ function XbNovaChat({
1602
1628
  className: "flex shrink-0 items-center justify-center",
1603
1629
  style: { width: collapsedSize, height: collapsedSize },
1604
1630
  children: /* @__PURE__ */ jsx7(
1605
- "span",
1631
+ Avatar,
1606
1632
  {
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"
1633
+ branding: onboarding?.branding,
1634
+ className: "h-7 w-7 text-[11px] shadow-md",
1635
+ gradient: avatarGradientStyle
1610
1636
  }
1611
1637
  )
1612
1638
  }
@@ -1650,11 +1676,11 @@ function XbNovaChat({
1650
1676
  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
1677
  /* @__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
1678
  /* @__PURE__ */ jsx7(
1653
- "span",
1679
+ Avatar,
1654
1680
  {
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"
1681
+ branding: onboarding?.branding,
1682
+ className: "h-6 w-6 shrink-0 text-[10px]",
1683
+ gradient: avatarGradientStyle
1658
1684
  }
1659
1685
  ),
1660
1686
  /* @__PURE__ */ jsxs3("div", { className: "min-w-0 flex-1", children: [
@@ -1681,12 +1707,24 @@ function XbNovaChat({
1681
1707
  return /* @__PURE__ */ jsx7(Bubble, { role: "assistant", children: /* @__PURE__ */ jsx7(AssistantMarkdown, { text: part.text }) }, j);
1682
1708
  }
1683
1709
  if (clientActionHandlers.has(part.toolName)) {
1710
+ const result = clientResults[part.toolCallId];
1711
+ if (result) {
1712
+ return /* @__PURE__ */ jsx7(
1713
+ ToolView,
1714
+ {
1715
+ part: { ...part, state: "output", view: result.view, output: result.output },
1716
+ viewRenderers,
1717
+ onVerdictAction
1718
+ },
1719
+ j
1720
+ );
1721
+ }
1684
1722
  const spec = clientActionHandlers.get(part.toolName);
1685
1723
  return spec?.render ? /* @__PURE__ */ jsx7("div", { children: spec.render(part.input) }, j) : null;
1686
1724
  }
1687
1725
  return /* @__PURE__ */ jsx7(ToolView, { part, viewRenderers, onVerdictAction }, j);
1688
1726
  }),
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
1727
+ !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
1728
  ] }) }, i)),
1691
1729
  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
1730
  /* @__PURE__ */ jsx7("div", { ref: bottomRef })
@@ -1757,6 +1795,34 @@ function XbNovaChat({
1757
1795
  )
1758
1796
  ] });
1759
1797
  }
1798
+ function Avatar({
1799
+ branding,
1800
+ className,
1801
+ gradient
1802
+ }) {
1803
+ const logoUrl = branding?.logo_url?.trim() ?? "";
1804
+ const [failedUrl, setFailedUrl] = useState4("");
1805
+ if (logoUrl && failedUrl !== logoUrl) {
1806
+ return /* @__PURE__ */ jsx7(
1807
+ "img",
1808
+ {
1809
+ src: logoUrl,
1810
+ alt: "",
1811
+ onError: () => setFailedUrl(logoUrl),
1812
+ className: `${className} flex-none rounded-full bg-white object-cover`
1813
+ }
1814
+ );
1815
+ }
1816
+ const text = branding?.avatar_text?.trim() || "AI";
1817
+ return /* @__PURE__ */ jsx7(
1818
+ "span",
1819
+ {
1820
+ className: `${className} flex items-center justify-center rounded-full font-bold text-white`,
1821
+ style: gradient,
1822
+ children: text
1823
+ }
1824
+ );
1825
+ }
1760
1826
  function Bubble({ role, children }) {
1761
1827
  const mine = role === "user";
1762
1828
  return /* @__PURE__ */ jsx7("div", { className: mine ? "flex justify-end" : "flex justify-start", children: /* @__PURE__ */ jsx7(