ibc-ai-web-sdk 2.1.0 → 2.1.2

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/README.md CHANGED
@@ -84,39 +84,75 @@ AIWebSDK.create({
84
84
  | `headers` | object | `{}` | | 附加请求头 |
85
85
  | `extendInfo` | object | `{}` | | 随请求发送的扩展信息 |
86
86
 
87
- ### Token 刷新
87
+ ### Token 管理
88
88
 
89
- 提供 `refreshTokenFn` 即可,SDK 会自动处理过期检测和刷新。
89
+ SDK Token 生命周期**应与应用层保持一致**,建议应用层为 SDK 签发的 Token TTL 与自身登录态 TTL 相同。
90
+
91
+ #### 正常刷新
92
+
93
+ 提供 `refreshTokenFn`,SDK 会在 Token 即将过期时自动刷新(默认提前 60 秒)。
90
94
 
91
95
  ```js
92
96
  createAIChatDialog({
93
- token: 'initial-access-token',
94
- tokenExpiresAt: Date.now() + 30 * 60 * 1000, // Token 过期时间戳
95
- refreshToken: 'initial-refresh-token', // 可选
97
+ token: 'st-xxxx', // 由应用后端调 /v1/token/create 签发
98
+ tokenExpiresAt: Date.now() + 7200 * 1000, // SDK Token 过期时间戳(毫秒),建议与用户登录态一致
99
+ refreshToken: 'initial-refresh-token', // 可选:服务端刷新凭证
96
100
 
97
101
  refreshTokenFn: async ({ token, refreshToken, userId }) => {
98
- const res = await fetch('/api/auth/refresh', {
102
+ // 调自己的后端接口,后端用 API-Key /v1/token/create 拿新 st- Token
103
+ const res = await fetch('/api/auth/refresh-sdk-token', {
99
104
  method: 'POST',
100
105
  headers: { 'Content-Type': 'application/json' },
101
- body: JSON.stringify({ token, refreshToken, userId })
106
+ body: JSON.stringify({ userId })
102
107
  });
103
108
  const data = await res.json();
104
109
  return {
105
- token: data.accessToken,
110
+ token: data.token,
106
111
  expiresAt: data.expiresAt * 1000
107
112
  };
108
113
  }
109
114
  });
110
115
  ```
111
116
 
117
+ #### Token 彻底失效
118
+
119
+ 当 Token 过期且刷新失败(如刷新接口返回 401),或未配置 `refreshTokenFn` 时,SDK 会触发 `onAuthExpired` 回调。**应用层应在此回调中引导用户重新登录。**
120
+
121
+ ```js
122
+ createAIChatDialog({
123
+ token: 'st-xxxx',
124
+ tokenExpiresAt: Date.now() + 7200 * 1000,
125
+
126
+ onAuthExpired: ({ reason, message }) => {
127
+ console.log('SDK Token 已失效:', reason, message);
128
+ dialog.destroy(); // 销毁 SDK 实例
129
+ router.push('/login'); // 引导用户重新登录
130
+ }
131
+ });
132
+
133
+ // 登录成功后:重新拿 Token,重新初始化 SDK
134
+ async function afterLogin(userId) {
135
+ // 1. 调后端通过 API-Key 签新 st- Token
136
+ const { token, expiresAt } = await fetchNewSdkToken(userId);
137
+ // 2. 重新初始化 SDK
138
+ dialog = createAIChatDialog({
139
+ token,
140
+ tokenExpiresAt: expiresAt * 1000,
141
+ onAuthExpired: () => { /* 同上 */ }
142
+ });
143
+ }
144
+ ```
145
+
112
146
  | 参数 | 类型 | 说明 |
113
147
  |------|------|------|
114
- | `token` | string | 认证 Token |
115
- | `tokenExpiresAt` | number | Token 过期时间戳(毫秒) |
116
- | `refreshToken` | string | refresh token |
148
+ | `token` | string | 认证 Token(由后端用 API-Key 调 `/v1/token/create` 签发) |
149
+ | `tokenExpiresAt` | number | Token 过期时间戳(毫秒)。建议与应用登录态 TTL 一致 |
150
+ | `tokenRefreshBufferMs` | number | 过期前多久提前刷新(毫秒),默认 60000 |
151
+ | `refreshToken` | string | 后端刷新凭证(可选) |
117
152
  | `refreshTokenFn` | function | 刷新函数,返回新 token 信息 |
118
153
  | `onTokenRefreshed` | function | 刷新成功回调 |
119
- | `onTokenExpired` | function | 刷新失败回调 |
154
+ | `onAuthExpired` | function | 认证彻底失效回调。**在此处引导用户重新登录** |
155
+
120
156
  ```
121
157
 
122
158
  ### 高级配置
@@ -431,7 +467,9 @@ data: {"conversationId":"conv_xxx","tokens":{"input":10,"output":15}}
431
467
 
432
468
  ### 认证
433
469
 
434
- 所有请求自动携带 `Authorization: Bearer {token}`。提供 `refreshTokenFn` Token 过期会自动刷新,无需手动处理。
470
+ 所有请求自动携带 `Authorization: Bearer {token}`。Token 由应用后端通过 API-Key 调用 `/v1/token/create` 签发,建议 TTL 与应用登录态保持一致。
471
+
472
+ Token 过期后 SDK 触发 `onAuthExpired` 回调,应用层应引导用户重新登录。登录成功后调用 `updateConfig({ token, tokenExpiresAt, ... })` 或重新初始化 SDK 传入新 Token。
435
473
 
436
474
  ---
437
475
 
package/dist/index.cjs.js CHANGED
@@ -195,12 +195,19 @@ class AIChatClient {
195
195
  silent = false
196
196
  } = {}) {
197
197
  if (typeof this.config.refreshTokenFn !== 'function') {
198
- const error = new Error('Token 已过期且未配置 refreshTokenFn');
198
+ const authErr = new Error('Token 已过期且未配置 refreshTokenFn');
199
+ authErr._authExpired = true;
200
+ authErr._authReason = reason;
199
201
  if (!silent) {
200
202
  this.stopBackgroundRefresh();
201
- this.config.onTokenExpired?.(error);
203
+ this.config.onAuthExpired?.({
204
+ reason,
205
+ message: authErr.message,
206
+ noRefreshFn: true,
207
+ error: authErr
208
+ });
202
209
  }
203
- throw error;
210
+ throw authErr;
204
211
  }
205
212
  if (!this.refreshTokenPromise) {
206
213
  this.refreshTokenPromise = Promise.resolve().then(() => this.config.refreshTokenFn({
@@ -227,8 +234,17 @@ class AIChatClient {
227
234
  return this.config.token || '';
228
235
  }
229
236
  this.stopBackgroundRefresh();
230
- this.config.onTokenExpired?.(error);
231
- throw error;
237
+ const errMsg = error && (error.message || String(error)) || 'Token 刷新失败,请重新登录';
238
+ this.config.onAuthExpired?.({
239
+ reason,
240
+ message: errMsg,
241
+ noRefreshFn: false,
242
+ error
243
+ });
244
+ const authErr = new Error(errMsg);
245
+ authErr._authExpired = true;
246
+ authErr._authReason = reason;
247
+ throw authErr;
232
248
  }).finally(() => {
233
249
  this.refreshTokenPromise = null;
234
250
  });
@@ -360,6 +376,9 @@ class AIChatClient {
360
376
  }
361
377
  return result;
362
378
  } catch (error) {
379
+ if (error && error._authExpired) {
380
+ return this.createError(ERROR_CODES.AUTH_FAILED, error.message || '登录已过期,请重新登录');
381
+ }
363
382
  if (error && error.name === 'AbortError') {
364
383
  return this.createError(ERROR_CODES.TIMEOUT_ERROR, '请求超时,请稍后重试');
365
384
  }
@@ -454,6 +473,11 @@ class AIChatClient {
454
473
  try {
455
474
  return await this.executeMessageStream(prompt, handlers, options, false);
456
475
  } catch (error) {
476
+ if (error && error._authExpired) {
477
+ const message = error.message || '登录已过期,请重新登录';
478
+ handlers.onError?.(message, error);
479
+ return this.createError(ERROR_CODES.AUTH_FAILED, message);
480
+ }
457
481
  if (error && error.name === 'AbortError') {
458
482
  const message = '请求超时或已取消';
459
483
  handlers.onError?.(message, error.sseData || error);
@@ -3598,7 +3622,7 @@ class AIChatDialog extends HTMLElement {
3598
3622
  tokenRefreshBufferMs: 60000,
3599
3623
  refreshTokenFn: null,
3600
3624
  onTokenRefreshed: null,
3601
- onTokenExpired: null,
3625
+ onAuthExpired: null,
3602
3626
  authErrorCodes: [],
3603
3627
  title: 'AI 助手',
3604
3628
  welcomeText: 'Hi,我是智能助手 ~\n欢迎随时提问',
@@ -3634,6 +3658,8 @@ class AIChatDialog extends HTMLElement {
3634
3658
  debug: false,
3635
3659
  // P4: 自定义消息操作按钮 — 返回数组 [{label, className?, onClick}] 在 AI 消息下方渲染
3636
3660
  onMessageActions: null,
3661
+ // P5: 浮窗按钮显隐控制 — false 时浮窗按钮隐藏,对话框只能通过 API 打开(登录页等场景)
3662
+ floatButton: true,
3637
3663
  suggestions: [{
3638
3664
  label: '帮我购买一台轻量应用服务器用于部署应用',
3639
3665
  value: '帮我购买一台轻量应用服务器用于部署应用'
@@ -3647,6 +3673,7 @@ class AIChatDialog extends HTMLElement {
3647
3673
  this._chatClient = new AIChatClient(this._config);
3648
3674
  if (this._conversationId) this._chatClient.setConversationId(this._conversationId);
3649
3675
  this.applyConfig();
3676
+ this.setFloatButtonVisible(this._config.floatButton !== false);
3650
3677
  // P1-2 fix: 在 _config 就绪后初始化拖拽,以正确读取 enableDrag
3651
3678
  if (this._config?.enableDrag !== false && this.getAttribute('mode') !== 'inline' && !this._dragInitialized) {
3652
3679
  this.initDraggable();
@@ -3683,6 +3710,21 @@ class AIChatDialog extends HTMLElement {
3683
3710
  if (this._chatClient) this._chatClient.dispose();
3684
3711
  if (this.parentNode) this.parentNode.removeChild(this);
3685
3712
  }
3713
+ // P5: 运行时动态控制浮窗按钮显隐(SPA 路由切换场景:登录页隐藏,业务页显示)
3714
+ setFloatButtonVisible(show) {
3715
+ if (!this._config) this.init({
3716
+ floatButton: show
3717
+ });else this._config.floatButton = show;
3718
+ const btn = this.shadowRoot?.querySelector('.float-button');
3719
+ if (btn) {
3720
+ const dialog = this.shadowRoot?.querySelector('.ai-chat-dialog');
3721
+ const isOpen = dialog?.classList.contains('dialog-visible');
3722
+ if (!isOpen) {
3723
+ btn.style.display = show ? 'flex' : 'none';
3724
+ }
3725
+ }
3726
+ return this;
3727
+ }
3686
3728
  addMessage(message) {
3687
3729
  if (!message || !message.content) return this;
3688
3730
  const msg = {
@@ -3725,6 +3767,7 @@ class AIChatDialog extends HTMLElement {
3725
3767
  if (!this._config.appId && this._config.agentAppId) this._config.appId = this._config.agentAppId;
3726
3768
  if (this._chatClient) this._chatClient.updateConfig(this._config);
3727
3769
  this.loadAppDetail();
3770
+ if ('floatButton' in config) this.setFloatButtonVisible(this._config.floatButton !== false);
3728
3771
  }
3729
3772
  this.applyConfig();
3730
3773
  return this;
@@ -3785,7 +3828,7 @@ class AIChatDialog extends HTMLElement {
3785
3828
  const dialog = this.shadowRoot.querySelector('.ai-chat-dialog');
3786
3829
  const btn = this.shadowRoot.querySelector('.float-button');
3787
3830
  if (dialog) dialog.classList.remove('dialog-visible');
3788
- if (btn) btn.style.display = 'flex';
3831
+ if (btn) btn.style.display = this._config?.floatButton !== false ? 'flex' : 'none';
3789
3832
  }
3790
3833
  // ==================== 渲染(完整CSS对齐theme.css) ====================
3791
3834
  render() {
@@ -6045,7 +6088,7 @@ class AIChatDialog extends HTMLElement {
6045
6088
  if (!text || typeof text !== 'string') return false;
6046
6089
  if (text === '\n' || text === '\n\n') return false;
6047
6090
  const p = text.trim();
6048
- if (!p) return true;
6091
+ if (!p) return false;
6049
6092
  return RUNTIME_PLACEHOLDERS.some(k => p.includes(k));
6050
6093
  }
6051
6094
  _isEventLike(text) {
@@ -6281,8 +6324,11 @@ class AIChatDialog extends HTMLElement {
6281
6324
  handleError(err) {
6282
6325
  let displayMsg = '抱歉,网络请求失败,请稍后重试';
6283
6326
  const errMsg = err?.message || '';
6327
+ const errCode = err?.code;
6284
6328
  // 按错误类型分类
6285
- if (errMsg.includes('Failed to fetch') || errMsg.includes('NetworkError') || errMsg.includes('网络')) {
6329
+ if (err?._authExpired || errCode === 40301) {
6330
+ displayMsg = '登录已过期,请重新登录后再试';
6331
+ } else if (errMsg.includes('Failed to fetch') || errMsg.includes('NetworkError') || errMsg.includes('网络')) {
6286
6332
  displayMsg = '网络连接失败,请检查网络后重试';
6287
6333
  } else if (errMsg.includes('AbortError') || errMsg.includes('超时') || errMsg.includes('timeout')) {
6288
6334
  displayMsg = '请求超时,请稍后重试';
@@ -6660,7 +6706,7 @@ function installVuePlugin(Vue) {
6660
6706
  }
6661
6707
  function useAIChat(options = {}) {
6662
6708
  if (typeof React === 'undefined') {
6663
- console.warn('[AI Web SDK] React is not loaded, useAIChat skipped');
6709
+ console.warn('[AI Web SDK] React is not loaded globally — useAIChat requires React on window (CDN mode) or use the imperative createAIChatDialog() API directly');
6664
6710
  return {
6665
6711
  open: () => {},
6666
6712
  close: () => {},
package/dist/index.esm.js CHANGED
@@ -191,12 +191,19 @@ class AIChatClient {
191
191
  silent = false
192
192
  } = {}) {
193
193
  if (typeof this.config.refreshTokenFn !== 'function') {
194
- const error = new Error('Token 已过期且未配置 refreshTokenFn');
194
+ const authErr = new Error('Token 已过期且未配置 refreshTokenFn');
195
+ authErr._authExpired = true;
196
+ authErr._authReason = reason;
195
197
  if (!silent) {
196
198
  this.stopBackgroundRefresh();
197
- this.config.onTokenExpired?.(error);
199
+ this.config.onAuthExpired?.({
200
+ reason,
201
+ message: authErr.message,
202
+ noRefreshFn: true,
203
+ error: authErr
204
+ });
198
205
  }
199
- throw error;
206
+ throw authErr;
200
207
  }
201
208
  if (!this.refreshTokenPromise) {
202
209
  this.refreshTokenPromise = Promise.resolve().then(() => this.config.refreshTokenFn({
@@ -223,8 +230,17 @@ class AIChatClient {
223
230
  return this.config.token || '';
224
231
  }
225
232
  this.stopBackgroundRefresh();
226
- this.config.onTokenExpired?.(error);
227
- throw error;
233
+ const errMsg = error && (error.message || String(error)) || 'Token 刷新失败,请重新登录';
234
+ this.config.onAuthExpired?.({
235
+ reason,
236
+ message: errMsg,
237
+ noRefreshFn: false,
238
+ error
239
+ });
240
+ const authErr = new Error(errMsg);
241
+ authErr._authExpired = true;
242
+ authErr._authReason = reason;
243
+ throw authErr;
228
244
  }).finally(() => {
229
245
  this.refreshTokenPromise = null;
230
246
  });
@@ -356,6 +372,9 @@ class AIChatClient {
356
372
  }
357
373
  return result;
358
374
  } catch (error) {
375
+ if (error && error._authExpired) {
376
+ return this.createError(ERROR_CODES.AUTH_FAILED, error.message || '登录已过期,请重新登录');
377
+ }
359
378
  if (error && error.name === 'AbortError') {
360
379
  return this.createError(ERROR_CODES.TIMEOUT_ERROR, '请求超时,请稍后重试');
361
380
  }
@@ -450,6 +469,11 @@ class AIChatClient {
450
469
  try {
451
470
  return await this.executeMessageStream(prompt, handlers, options, false);
452
471
  } catch (error) {
472
+ if (error && error._authExpired) {
473
+ const message = error.message || '登录已过期,请重新登录';
474
+ handlers.onError?.(message, error);
475
+ return this.createError(ERROR_CODES.AUTH_FAILED, message);
476
+ }
453
477
  if (error && error.name === 'AbortError') {
454
478
  const message = '请求超时或已取消';
455
479
  handlers.onError?.(message, error.sseData || error);
@@ -3594,7 +3618,7 @@ class AIChatDialog extends HTMLElement {
3594
3618
  tokenRefreshBufferMs: 60000,
3595
3619
  refreshTokenFn: null,
3596
3620
  onTokenRefreshed: null,
3597
- onTokenExpired: null,
3621
+ onAuthExpired: null,
3598
3622
  authErrorCodes: [],
3599
3623
  title: 'AI 助手',
3600
3624
  welcomeText: 'Hi,我是智能助手 ~\n欢迎随时提问',
@@ -3630,6 +3654,8 @@ class AIChatDialog extends HTMLElement {
3630
3654
  debug: false,
3631
3655
  // P4: 自定义消息操作按钮 — 返回数组 [{label, className?, onClick}] 在 AI 消息下方渲染
3632
3656
  onMessageActions: null,
3657
+ // P5: 浮窗按钮显隐控制 — false 时浮窗按钮隐藏,对话框只能通过 API 打开(登录页等场景)
3658
+ floatButton: true,
3633
3659
  suggestions: [{
3634
3660
  label: '帮我购买一台轻量应用服务器用于部署应用',
3635
3661
  value: '帮我购买一台轻量应用服务器用于部署应用'
@@ -3643,6 +3669,7 @@ class AIChatDialog extends HTMLElement {
3643
3669
  this._chatClient = new AIChatClient(this._config);
3644
3670
  if (this._conversationId) this._chatClient.setConversationId(this._conversationId);
3645
3671
  this.applyConfig();
3672
+ this.setFloatButtonVisible(this._config.floatButton !== false);
3646
3673
  // P1-2 fix: 在 _config 就绪后初始化拖拽,以正确读取 enableDrag
3647
3674
  if (this._config?.enableDrag !== false && this.getAttribute('mode') !== 'inline' && !this._dragInitialized) {
3648
3675
  this.initDraggable();
@@ -3679,6 +3706,21 @@ class AIChatDialog extends HTMLElement {
3679
3706
  if (this._chatClient) this._chatClient.dispose();
3680
3707
  if (this.parentNode) this.parentNode.removeChild(this);
3681
3708
  }
3709
+ // P5: 运行时动态控制浮窗按钮显隐(SPA 路由切换场景:登录页隐藏,业务页显示)
3710
+ setFloatButtonVisible(show) {
3711
+ if (!this._config) this.init({
3712
+ floatButton: show
3713
+ });else this._config.floatButton = show;
3714
+ const btn = this.shadowRoot?.querySelector('.float-button');
3715
+ if (btn) {
3716
+ const dialog = this.shadowRoot?.querySelector('.ai-chat-dialog');
3717
+ const isOpen = dialog?.classList.contains('dialog-visible');
3718
+ if (!isOpen) {
3719
+ btn.style.display = show ? 'flex' : 'none';
3720
+ }
3721
+ }
3722
+ return this;
3723
+ }
3682
3724
  addMessage(message) {
3683
3725
  if (!message || !message.content) return this;
3684
3726
  const msg = {
@@ -3721,6 +3763,7 @@ class AIChatDialog extends HTMLElement {
3721
3763
  if (!this._config.appId && this._config.agentAppId) this._config.appId = this._config.agentAppId;
3722
3764
  if (this._chatClient) this._chatClient.updateConfig(this._config);
3723
3765
  this.loadAppDetail();
3766
+ if ('floatButton' in config) this.setFloatButtonVisible(this._config.floatButton !== false);
3724
3767
  }
3725
3768
  this.applyConfig();
3726
3769
  return this;
@@ -3781,7 +3824,7 @@ class AIChatDialog extends HTMLElement {
3781
3824
  const dialog = this.shadowRoot.querySelector('.ai-chat-dialog');
3782
3825
  const btn = this.shadowRoot.querySelector('.float-button');
3783
3826
  if (dialog) dialog.classList.remove('dialog-visible');
3784
- if (btn) btn.style.display = 'flex';
3827
+ if (btn) btn.style.display = this._config?.floatButton !== false ? 'flex' : 'none';
3785
3828
  }
3786
3829
  // ==================== 渲染(完整CSS对齐theme.css) ====================
3787
3830
  render() {
@@ -6041,7 +6084,7 @@ class AIChatDialog extends HTMLElement {
6041
6084
  if (!text || typeof text !== 'string') return false;
6042
6085
  if (text === '\n' || text === '\n\n') return false;
6043
6086
  const p = text.trim();
6044
- if (!p) return true;
6087
+ if (!p) return false;
6045
6088
  return RUNTIME_PLACEHOLDERS.some(k => p.includes(k));
6046
6089
  }
6047
6090
  _isEventLike(text) {
@@ -6277,8 +6320,11 @@ class AIChatDialog extends HTMLElement {
6277
6320
  handleError(err) {
6278
6321
  let displayMsg = '抱歉,网络请求失败,请稍后重试';
6279
6322
  const errMsg = err?.message || '';
6323
+ const errCode = err?.code;
6280
6324
  // 按错误类型分类
6281
- if (errMsg.includes('Failed to fetch') || errMsg.includes('NetworkError') || errMsg.includes('网络')) {
6325
+ if (err?._authExpired || errCode === 40301) {
6326
+ displayMsg = '登录已过期,请重新登录后再试';
6327
+ } else if (errMsg.includes('Failed to fetch') || errMsg.includes('NetworkError') || errMsg.includes('网络')) {
6282
6328
  displayMsg = '网络连接失败,请检查网络后重试';
6283
6329
  } else if (errMsg.includes('AbortError') || errMsg.includes('超时') || errMsg.includes('timeout')) {
6284
6330
  displayMsg = '请求超时,请稍后重试';
@@ -6656,7 +6702,7 @@ function installVuePlugin(Vue) {
6656
6702
  }
6657
6703
  function useAIChat(options = {}) {
6658
6704
  if (typeof React === 'undefined') {
6659
- console.warn('[AI Web SDK] React is not loaded, useAIChat skipped');
6705
+ console.warn('[AI Web SDK] React is not loaded globally — useAIChat requires React on window (CDN mode) or use the imperative createAIChatDialog() API directly');
6660
6706
  return {
6661
6707
  open: () => {},
6662
6708
  close: () => {},
package/dist/index.umd.js CHANGED
@@ -197,12 +197,19 @@
197
197
  silent = false
198
198
  } = {}) {
199
199
  if (typeof this.config.refreshTokenFn !== 'function') {
200
- const error = new Error('Token 已过期且未配置 refreshTokenFn');
200
+ const authErr = new Error('Token 已过期且未配置 refreshTokenFn');
201
+ authErr._authExpired = true;
202
+ authErr._authReason = reason;
201
203
  if (!silent) {
202
204
  this.stopBackgroundRefresh();
203
- this.config.onTokenExpired?.(error);
205
+ this.config.onAuthExpired?.({
206
+ reason,
207
+ message: authErr.message,
208
+ noRefreshFn: true,
209
+ error: authErr
210
+ });
204
211
  }
205
- throw error;
212
+ throw authErr;
206
213
  }
207
214
  if (!this.refreshTokenPromise) {
208
215
  this.refreshTokenPromise = Promise.resolve().then(() => this.config.refreshTokenFn({
@@ -229,8 +236,17 @@
229
236
  return this.config.token || '';
230
237
  }
231
238
  this.stopBackgroundRefresh();
232
- this.config.onTokenExpired?.(error);
233
- throw error;
239
+ const errMsg = error && (error.message || String(error)) || 'Token 刷新失败,请重新登录';
240
+ this.config.onAuthExpired?.({
241
+ reason,
242
+ message: errMsg,
243
+ noRefreshFn: false,
244
+ error
245
+ });
246
+ const authErr = new Error(errMsg);
247
+ authErr._authExpired = true;
248
+ authErr._authReason = reason;
249
+ throw authErr;
234
250
  }).finally(() => {
235
251
  this.refreshTokenPromise = null;
236
252
  });
@@ -362,6 +378,9 @@
362
378
  }
363
379
  return result;
364
380
  } catch (error) {
381
+ if (error && error._authExpired) {
382
+ return this.createError(ERROR_CODES.AUTH_FAILED, error.message || '登录已过期,请重新登录');
383
+ }
365
384
  if (error && error.name === 'AbortError') {
366
385
  return this.createError(ERROR_CODES.TIMEOUT_ERROR, '请求超时,请稍后重试');
367
386
  }
@@ -456,6 +475,11 @@
456
475
  try {
457
476
  return await this.executeMessageStream(prompt, handlers, options, false);
458
477
  } catch (error) {
478
+ if (error && error._authExpired) {
479
+ const message = error.message || '登录已过期,请重新登录';
480
+ handlers.onError?.(message, error);
481
+ return this.createError(ERROR_CODES.AUTH_FAILED, message);
482
+ }
459
483
  if (error && error.name === 'AbortError') {
460
484
  const message = '请求超时或已取消';
461
485
  handlers.onError?.(message, error.sseData || error);
@@ -3600,7 +3624,7 @@ Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error
3600
3624
  tokenRefreshBufferMs: 60000,
3601
3625
  refreshTokenFn: null,
3602
3626
  onTokenRefreshed: null,
3603
- onTokenExpired: null,
3627
+ onAuthExpired: null,
3604
3628
  authErrorCodes: [],
3605
3629
  title: 'AI 助手',
3606
3630
  welcomeText: 'Hi,我是智能助手 ~\n欢迎随时提问',
@@ -3636,6 +3660,8 @@ Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error
3636
3660
  debug: false,
3637
3661
  // P4: 自定义消息操作按钮 — 返回数组 [{label, className?, onClick}] 在 AI 消息下方渲染
3638
3662
  onMessageActions: null,
3663
+ // P5: 浮窗按钮显隐控制 — false 时浮窗按钮隐藏,对话框只能通过 API 打开(登录页等场景)
3664
+ floatButton: true,
3639
3665
  suggestions: [{
3640
3666
  label: '帮我购买一台轻量应用服务器用于部署应用',
3641
3667
  value: '帮我购买一台轻量应用服务器用于部署应用'
@@ -3649,6 +3675,7 @@ Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error
3649
3675
  this._chatClient = new AIChatClient(this._config);
3650
3676
  if (this._conversationId) this._chatClient.setConversationId(this._conversationId);
3651
3677
  this.applyConfig();
3678
+ this.setFloatButtonVisible(this._config.floatButton !== false);
3652
3679
  // P1-2 fix: 在 _config 就绪后初始化拖拽,以正确读取 enableDrag
3653
3680
  if (this._config?.enableDrag !== false && this.getAttribute('mode') !== 'inline' && !this._dragInitialized) {
3654
3681
  this.initDraggable();
@@ -3685,6 +3712,21 @@ Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error
3685
3712
  if (this._chatClient) this._chatClient.dispose();
3686
3713
  if (this.parentNode) this.parentNode.removeChild(this);
3687
3714
  }
3715
+ // P5: 运行时动态控制浮窗按钮显隐(SPA 路由切换场景:登录页隐藏,业务页显示)
3716
+ setFloatButtonVisible(show) {
3717
+ if (!this._config) this.init({
3718
+ floatButton: show
3719
+ });else this._config.floatButton = show;
3720
+ const btn = this.shadowRoot?.querySelector('.float-button');
3721
+ if (btn) {
3722
+ const dialog = this.shadowRoot?.querySelector('.ai-chat-dialog');
3723
+ const isOpen = dialog?.classList.contains('dialog-visible');
3724
+ if (!isOpen) {
3725
+ btn.style.display = show ? 'flex' : 'none';
3726
+ }
3727
+ }
3728
+ return this;
3729
+ }
3688
3730
  addMessage(message) {
3689
3731
  if (!message || !message.content) return this;
3690
3732
  const msg = {
@@ -3727,6 +3769,7 @@ Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error
3727
3769
  if (!this._config.appId && this._config.agentAppId) this._config.appId = this._config.agentAppId;
3728
3770
  if (this._chatClient) this._chatClient.updateConfig(this._config);
3729
3771
  this.loadAppDetail();
3772
+ if ('floatButton' in config) this.setFloatButtonVisible(this._config.floatButton !== false);
3730
3773
  }
3731
3774
  this.applyConfig();
3732
3775
  return this;
@@ -3787,7 +3830,7 @@ Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error
3787
3830
  const dialog = this.shadowRoot.querySelector('.ai-chat-dialog');
3788
3831
  const btn = this.shadowRoot.querySelector('.float-button');
3789
3832
  if (dialog) dialog.classList.remove('dialog-visible');
3790
- if (btn) btn.style.display = 'flex';
3833
+ if (btn) btn.style.display = this._config?.floatButton !== false ? 'flex' : 'none';
3791
3834
  }
3792
3835
  // ==================== 渲染(完整CSS对齐theme.css) ====================
3793
3836
  render() {
@@ -6047,7 +6090,7 @@ Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error
6047
6090
  if (!text || typeof text !== 'string') return false;
6048
6091
  if (text === '\n' || text === '\n\n') return false;
6049
6092
  const p = text.trim();
6050
- if (!p) return true;
6093
+ if (!p) return false;
6051
6094
  return RUNTIME_PLACEHOLDERS.some(k => p.includes(k));
6052
6095
  }
6053
6096
  _isEventLike(text) {
@@ -6283,8 +6326,11 @@ Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error
6283
6326
  handleError(err) {
6284
6327
  let displayMsg = '抱歉,网络请求失败,请稍后重试';
6285
6328
  const errMsg = err?.message || '';
6329
+ const errCode = err?.code;
6286
6330
  // 按错误类型分类
6287
- if (errMsg.includes('Failed to fetch') || errMsg.includes('NetworkError') || errMsg.includes('网络')) {
6331
+ if (err?._authExpired || errCode === 40301) {
6332
+ displayMsg = '登录已过期,请重新登录后再试';
6333
+ } else if (errMsg.includes('Failed to fetch') || errMsg.includes('NetworkError') || errMsg.includes('网络')) {
6288
6334
  displayMsg = '网络连接失败,请检查网络后重试';
6289
6335
  } else if (errMsg.includes('AbortError') || errMsg.includes('超时') || errMsg.includes('timeout')) {
6290
6336
  displayMsg = '请求超时,请稍后重试';
@@ -6662,7 +6708,7 @@ Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error
6662
6708
  }
6663
6709
  function useAIChat(options = {}) {
6664
6710
  if (typeof React === 'undefined') {
6665
- console.warn('[AI Web SDK] React is not loaded, useAIChat skipped');
6711
+ console.warn('[AI Web SDK] React is not loaded globally — useAIChat requires React on window (CDN mode) or use the imperative createAIChatDialog() API directly');
6666
6712
  return {
6667
6713
  open: () => {},
6668
6714
  close: () => {},