# 数字员工聊天组件

## 数字员工聊天组件概述

### 简介

**数字员工聊天组件** 是一个基于 React 开发的聊天组件，提供完整的对话交互能力。通过 OpenSDK 体系对外开放，第三方可以快速集成到自己的业务系统中。

#### 核心特性

- ✅ **完整对话能力**：支持文本对话、流式输出、打字机效果
- ✅ **会话管理**：新建对话、历史会话、会话切换
- ✅ **知识库上传**：支持对话携带知识库
- ✅ **富文本渲染**：Markdown、代码高亮、数学公式、Mermaid 图表
- ✅ **国际化**：支持中文简体、繁体、英文
- ✅ **事件监听**：完整的生命周期事件通知
- ✅ **自定义扩展**：支持自定义按钮、样式等

---
### 效果演示：
预览页地址：https://ark.wps.cn/demo/pages/web-sdk

![image-20251124105951453](https://cloudcdn.qwps.cn/open/_img/2eadda3c40.png)

![1762341415248](https://cloudcdn.qwps.cn/open/_img/ea7d4eae8e.gif)

## 开发必读

### 前置准备

+ 准备好数字员工的 `agentId`（应用AK）

  > 去[数字员工平台](https://agentspace.wps.cn/build) 创建一个应用 

  ![](https://cloudcdn.qwps.cn/open/_img/548b396261.png)

+ 完成365API权限相关配置   

  详见:[鉴权流程说明](./web-component-overview.md#鉴权流程)

  > 在数字员工平台申请相关聊天对话组件需要的scope 

  1.agentspace_chat需要申请的权限：

  | **功能名称**         | **scope**                     | **业务域** |
  | :------------------- | :---------------------------- | :----------- |
  | 数字员工智能问答组件 | kso.component.agentspace_chat | 开放组件     |
  | 查询和管理知识库 | kso.wiki.readwrite | 知识库     |

  ![image-20251121182459195](https://cloudcdn.qwps.cn/open/_img/eb8e43d228.png)

  ![image-20251121182511131](https://cloudcdn.qwps.cn/open/_img/01f9e804c4.png )
  将开放能力打开
  ![open](https://cloudcdn.qwps.cn/open/_img/cbe21bfdb2.png )
  2、配置您的可信域名

  ![1762325488352](https://cloudcdn.qwps.cn/open/_img/4428bbf752.png)

+ 去开放平台获取该应用的AK和SK

  >  访问 https://365.kdocs.cn/3rd/open/developer/manager/{your-agentId}}/app-info

  ![image-20251118105244812](https://cloudcdn.qwps.cn/open/_img/6fd9bb8f1a.png)

+ 授权回调地址

  需要`用户授权`的开放组件，需要配置OAuth2回调地址

  > 【https://agentspace.wps.cn/sdk-callback】

  - 对接用户授权对接流程：[鉴权流程说明](./web-component-overview.md#鉴权流程)

  ![img](https://cloudcdn.qwps.cn/open/_img/e8ed719c18.png)

---

+ 发布版本

  >  返回数字员工平台,在您创建的应用中发布一个版本

  ![image-20251118110104674](https://cloudcdn.qwps.cn/open/_img/96e616377d.png)

## 快速开始

如果想要更加便捷的配置 直接查看[完整示例](#完整使用示例)

### 引入OpenSDK

 **您可以直接下载opensdK到您自己的项目或者直接引入在线CDN**

> OpenSDK地址：https://qn.cache.wpscdn.cn/open_static/libs/open-sdk/0.0.2/open-sdk.0.0.2.umd.js

#### 在HTML页面中引入OpenSDK

```html
<!doctype html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>AgentSpace Chat SDK Demo</title>
  <style>
    body {
      width: 100vw;
      height: 100vh;
      overflow: hidden;
      background-color: #f7f8f9 !important;
    }

    #agentspace-container {
      height: 100vh;
      width: 100vw;
    }
  </style>
</head>

<body>
  <div id="root" class="height-100vh width-100vw">
    <div id="agentspace-container" class="height-100vh width-100vw"></div>
  </div>
    //引入opensdk
    <script src="https://qn.cache.wpscdn.cn/open_static/libs/open-sdk/0.0.2/open-sdk.0.0.2.umd.js"></script>

    <script>
      ....业务方需要实现的代码... (见下)
      startApp();
    </script>
</body>
</html>
```
### 引入数字员工聊天组件
您可以直接下载数字员工组件UMD包到您自己的项目或者直接引入在线CDN
> agent_chat地址：https://qn.cache.wpscdn.cn/open_static/libs/widgets/agentspace_chat/1.0.8/kso.component.agentspace_chat.1.0.8.umd.js

#### React项目导入
1. 确保项目中依赖react>=18.0.0，react-dom>=18.0.0
2. 将依赖挂载到指定的全局对象window.AgentSpaceWebSDKDeps中
3. 依赖挂载后通过script加载SDK的UMD包
```javascript
// 示例导入代码
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as ReactDOMClient from 'react-dom/client';
import * as ReactJSXRuntime from 'react/jsx-runtime';

// 单例加载 UMD 脚本：确保在使用 OpenSDK.create/mount 前，组件库已完成加载
let agentspaceChatUmdLoadPromise: Promise<void> | null = null;

// 加载web SDK的UMD包
function loadAgentspaceChatUmd() {
  if (agentspaceChatUmdLoadPromise) return agentspaceChatUmdLoadPromise;

  agentspaceChatUmdLoadPromise = new Promise<void>((resolve) => {
    // UMD 包在浏览器环境下会从window读取这三个依赖，需要在script加载前挂载
     (window as any).AgentSpaceWebSDKDeps = {
      React:React,
      ReactDOM: {
        ...ReactDOM,
        createRoot:ReactDOMClient.createRoot, // React18的createRoot在react-dom/client上
      },
      ReactJSXRuntime:ReactJSXRuntime,
    };
    // 通过script加载组件库
    const script = document.createElement('script');
    // 通过script加载，从CDN导入SDK
    script.src = 'https://qn.cache.wpscdn.cn/open_static/libs/widgets/agentspace_chat/1.0.8/kso.component.agentspace_chat.1.0.8.umd.js';
    script.onload = () => resolve();
    document.head.appendChild(script);
  });
  return agentspaceChatUmdLoadPromise;
}

// 加载组件
const tryRenderWidget = async () => {
    if (hasRendered) return;
    try {
      // 确保 UMD 组件库已加载完成，再创建实例/渲染
      await loadAgentspaceChatUmd();

      // 鉴权初始化流程
      ......

      // 创建 agentspaceChat 实例
      const agentspaceChatInstance = window.OpenSDK.create('kso-agentspace-chat', {
        agentId: "your-agentId",
      });

      await agentspaceChatInstance.mount(containerRef.current);

      console.log('[渲染组件] 组件渲染成功');

    } catch (error) {
      console.error('[渲染组件] 渲染失败:', error);
    }
};
```

### 鉴权配置

在创建组件实例前，需要先完成授权配置：

```javascript
// 定义所需的权限范围
const defaultScopes = [
  'kso.component.agentspace_chat',   // 数字员工聊天组件权限（必需）
  'kso.wiki.readwrite' // 知识库管理和查询全选（必需）
];

// 从 localStorage 获取已保存的权限，或使用默认值
const savedScopes = localStorage.getItem('merged_scopes');
let scopes = savedScopes ? JSON.parse(savedScopes) : defaultScopes;

// OAuth2 授权函数
async function authorize(scopes) {
  OpenSDK.OAuth2.authorize({
    appId: 'YOUR_APP_ID',                                   // 必填：您的应用 ID
    redirect_uri: 'https://agentspace.wps.cn/sdk-callback', // 必填：回调地址（需要在需要在开放平台配置授权回调地址）
    scope: scopes.join(','),                        
    mode: OpenSDK.OAuth2.Mode.POPUP,               	 // 必填：授权模式（POPUP 或 REDIRECT）
    state: 'agent-space-chat',                       // 可选：自定义状态参数
  });
}
```

### 初始化流程
```javascript
// React，ReactDOM，ReactJSXRuntime挂载到AgentSpaceWebSDKDeps中

window.AgentSpaceWebSDKDeps = {
  React: React,
  ReactDOM: ReactDOM,
  ReactJSXRuntime: jsxRuntime,
};

```
```javascript
/**
 * 主应用逻辑
 */

let agentspaceChatUmdLoadPromise = null;

// 加载第三方依赖
function loadScript(src) {
  return new Promise((resolve, reject) => {
    // 已存在则复用
    const existing = document.querySelector(`script[src="${src}"]`);
    if (existing) {
      if (existing.dataset.loaded === "1") return resolve();
      existing.addEventListener("load", resolve, { once: true });
      existing.addEventListener("error", reject, { once: true });
      return;
    }

    const s = document.createElement("script");
    s.src = src;
    s.async = true;
    s.addEventListener("load", () => {
      s.dataset.loaded = "1";
      resolve();
    });
    s.addEventListener("error", reject);
    document.head.appendChild(s);
  });
}

/**
 * 加载 agentspace_chat UMD
 */
function loadAgentspaceChatUmd() {
  if (agentspaceChatUmdLoadPromise) return agentspaceChatUmdLoadPromise;

  agentspaceChatUmdLoadPromise = (async () => {
    await loadScript(
      "https://qn.cache.wpscdn.cn/open_static/libs/widgets/agentspace_chat/1.0.8/kso.component.agentspace_chat.1.0.8.umd.js"
    );
  })();

  return agentspaceChatUmdLoadPromise;
}


let hasRender = false;

// localStorage.scopes || 使用默认值
const savedScopes = localStorage.getItem('merged_scopes');
let scopes = savedScopes ? JSON.parse(savedScopes) : defaultScopes;

async function startApp() {
  if (hasRender) return;
  OpenSDK.setDebug(true);
  // 检查app_config是否过期
  const app_config = localStorage.getItem('app_config');
  if (app_config) {
    const {
      app_id,
      signature,
      noncestr,
      timestamp,
      tag,
      url,
      app_config_timestamp,
    } = JSON.parse(app_config);
    console.log('app_config_timestamp', Date.now() - app_config_timestamp);
    if (Date.now() - app_config_timestamp < 7200000) {
      await OpenSDK.config({
        scopes,
        signature, // 签名
        appId: app_id, // 应用 appId
        timestamp, // 时间戳（毫秒）
        nonceStr: noncestr, // 随机字符串
        tag,
        url,
      });

      // 创建组件
      renderWidget();
      return;
    } else {
      localStorage.removeItem('app_config');
      // 缓存过期，重新开始授权流程
      // console.log('[授权流程] app_config 缓存已过期，重新授权')
      window.location.reload();
    }
  }

  // 无缓存或缓存过期，开始授权
  console.log('[授权流程] 开始授权');
  authorize(scopes);

  OpenSDK.addEventListener(OpenSDK.Events.OAuth2Message, async (event) => {
    // 安全起见，判断来源
    if (event.origin !== 'https://agentspace.wps.cn') return;
    const code = event.data.code;
    console.log('OAuth2Message code', code);

    // 步骤一：拿到临时授权码code，提交给服务端申请access_token
    // 应用授权，无需走OAuth授权流程，直接申请access_token
    // 用户授权，走OAuth授权流程，获取auth_code，然后申请access_token
    if (!code) {
      authorize(scopes);
      return;
    }

    // 步骤二：获取biz-server 申请的js_ticket相关签名信息
    const { app_id, signature, noncestr, timestamp, tag, url } = await fetch(
      `${host}/api/get_app_config?code=${code}`
    ).then(async (res) => {
      const resp = await res.json();
      if (resp?.code === 0) {
        return resp.data || {};
      } else {
        throw new Error('获取signature失败');
      }
    });
    // 先删除缓存的 app_config
    localStorage.removeItem('app_config');
    // 将获取的信息缓存存储，两个小时后过期
    localStorage.setItem(
      'app_config',
      JSON.stringify({
        app_id,
        signature,
        noncestr,
        timestamp,
        tag,
        url,
        app_config_timestamp: Date.now(),
      })
    );
    // 步骤三：OpenSDK设置签名信息
    await OpenSDK.config({
      scopes,
      signature, // 签名
      appId: app_id, // 应用 appId
      timestamp, // 时间戳（毫秒）
      nonceStr: noncestr, // 随机字符串
      tag,
      url,
    });
    // 步骤四：渲染组件
    renderWidget();
  });
}
```

### 创建组件实例

```javascript
async function renderWidget() {
  // 加载 agentspace_chat UMD
  await loadAgentspaceChatUmd();

  if (OpenSDK.components.size === 0) return;
  const agentspaceChat = OpenSDK.create('kso-agentspace-chat', {
    agentId: agentConfig.agentId,
  });
  agentspaceChat.mount(document.getElementById('agentspace-container'));
  hasRender = true;
  // 监听scopes范围不够，二次授权
  agentspaceChat.addEventListener(
    ...... 见3.5 监听事件
  );
}
```

### 监听事件

详见[事件监听](#事件监听),此处展示必要的监听事件

```javascript
  agentspaceChat.addEventListener(
    agentspaceChat.Events.OnAuthRequest,
    async (e) => {
      const detailScopeUser = e.detailScopeUser || []; 
      // detail接口返回的所有required scopes与本地配置的scopes进行对比，判断缺失的scopes
      const missingScopes = detailScopeUser.filter(
        (scope) => !scopes.includes(scope)
      );
      // 如果没有缺失的权限，无需重新授权
      if (missingScopes.length === 0) {
        console.log('[业务方 addEventListener] 权限验证通过，无需重新授权');
        return;
      }
      // 合并 scopes
      const mergedScopes = [...new Set([...scopes, ...missingScopes])];
      scopes = mergedScopes;
      // 保存合并后的scopes
      localStorage.setItem('merged_scopes', JSON.stringify(mergedScopes));
      // 清除缓存的 app_config，强制重新获取新的授权信息
      localStorage.removeItem('app_config');
        
      // 显示自定义授权提示 UI（不使用 window.confirm）实现见授权提示弹窗
      showAuthPrompt(
        missingScopes,
        mergedScopes,
        // 确认授权回调
        (mergedScopes) => authorize(mergedScopes),
        // 取消授权回调
        () => {
          console.log('[业务方 addEventListener] 用户取消授权');
        }
      );
    }
  );
```

### 自定义授权提示ui

> showAuthPrompt( )

见 [授权提示弹窗](#auth-popup-helpersjs)

### 动态更新配置

```javascript
agentspaceChat.update({
  theme: 'dark',
  welcomeMessage: '配置已更新！我是您的智能助手，有什么可以帮助您的吗？',
  locale: 'en',
  placeholder: 'Please enter your question...',
});
```
### 发送消息
```javascript
// 发送消息的参数类型
interface ISendMessageParams {
  content: string;  // 消息内容
  knowledges?: string[];  // 知识库id数组
}

// 示例
agentspaceChat?.sendMessage({content:'你好'})
```

### 卸载组件

```javascript
agentspaceChat.unmount();
```


### 后端api接口示例

>  步骤1：用户授权，code 换 access_token, access_token 禁止返回前端保存,
>
>  开发者自行实现OAuth2授权，获取并维护access_token(示例见app.js和auth.js)
>
>  步骤2：业务方接口，获取用户信息，app，signature等
>
>  开发文档：https://365.kdocs.cn/3rd/open/documents/app-integration-dev/wps365/server/certification-authorization/user-authorization/flow.html

~~~js
const index: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
    //步骤一 获取access_token 
  fastify.get('/get_app_config', async function (request, reply) {
    // 此处简化获取access_token，js_ticket逻辑
    const { code = '' } = (request.query as { code: string }) || {};
    let user_access_token = '';
    const resp = await getAccessToken({
      code,
      agent_ak: config.agent_ak,
      agent_sk: config.agent_sk,
    });

    if (resp?.code === 0) {
      user_access_token = resp?.data?.user_token || '';

      if (!user_access_token) {
        return reply
          .status(400)
          .send({ code: 400, message: '获取access_token失败' });
      }
    } else {
      return reply
        .status(400)
        .send({ code: 400, message: '获取access_token失败' });
    }

    console.log('授权信息', {
      user_access_token,
      full_response: resp,
    });

    let jsapi_ticket = {
      ticket: '',
      tag: '',
    };
	//步骤二 获取jsapi_ticket
    if (!jsapi_ticket.ticket) {
      const ticketResp: any = await getJSAPITicket(user_access_token);
      console.log('js_ticket', ticketResp);
      if (ticketResp?.code === 0) {
        jsapi_ticket = ticketResp.data;
      } else {
        return reply
          .status(400)
          .send({ code: 400, message: '获取jsapi_ticket失败' });
      }
    }
    // 请求页面的url，不包含query,search
    const url = `http://${request.headers.host}`;
    console.log('url', url);
    const noncestr = Math.random().toString(36).substring(2, 15);
    const timestamp = (Date.now() / 1e3) | 0;
    console.log('timestamp', timestamp);
    const signature = genSignature(
      jsapi_ticket.ticket,
      noncestr,
      timestamp,
      url
    );

    return MakeSuccess({
      app_id: config.agent_ak,
      signature,
      noncestr,
      timestamp,
      tag: jsapi_ticket?.tag,
      url,
    });
  });
};
~~~

#### getAccessToken和getJSAPITicket函数

```ts
export async function getAccessToken(data: {
  code: string;
  agent_ak: string;
  agent_sk: string;
}): Promise<IResponse<any>> {
  if (!data.code) {
    return {
      code: 1,
      message: 'code is required',
      data: {},
    };
  }
  const userResp = await openApi.post<any, any>('/oauth2/token',{
      grant_type: 'authorization_code',
      client_id: data.agent_ak,
      client_secret: data.agent_sk,
      code: data.code,
      redirect_uri: 'https://agentspace.wps.cn/sdk-callback',
    },
    {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    }
  );
  if (userResp?.access_token) {
    return {
      code: 0,
      data: {
        user_token: userResp.access_token,
        user_auth: userResp,
      },
    };
  }
  return {
    code: 1,
    message: 'user authorization failed',
    data: {},
  };
}

export async function getJSAPITicket(
  access_token: string
): Promise<IResponse<any>> {
  const resp = await openApi.get('/oauth2/jsapi_ticket', {
    headers: {
      Authorization: `bearer ${access_token}`,
    },
  });
  return {
    code: 0,
    data: {
      ...(resp || {}),
    },
  };
}
```

## 事件监听

### 支持的事件类型

| 事件名称         | 说明                               | 事件数据                | 回调参数                                                     |
| :--------------- | :--------------------------------- | :---------------------- | :----------------------------------------------------------- |
| Updated          | 组件配置更新时触发                 | { info: string }        | info: 更新信息描述，如 "数字员工组件刷新完成"              |
| OnChatStart      | 对话开始时触发                     | { message: Message }    | message: 用户发送的消息对象（包含 id、text、sender、timestamp 等字段） |
| OnChatEnd        | 对话结束时触发                     | {}                      | 无参数（空对象）                                             |
| OnError          | 发生错误时触发                     | { error: Error &#124; any } |  error: 错误对象或错误信息                                    |
| OnMessageSend    | 用户发送消息时触发                 | { message: Message }    | message: 发送的消息对象（包含消息内容、发送者等信息）        |
| OnMessageReceive | 接收到 AI 回复时触发               | { message: Message }    | message: 接收的消息对象（可能包含 content_blocks、wps_tools 等富内容） |
| OnSelected       | 用户进行选择操作时触发             | any                     | 选择的内容（根据具体场景而定）                               |
| OnAuthRequest    | 需要额外权限时触发（用于二次授权） | AuthRequestEvent        | detailScopeUser: 所有必需权限数组 |


### 事件监听示例

```javascript
// 1. Updated 事件
agentspaceChat.addEventListener(agentspaceChat.Events.Updated, (e) => {
  console.log('[Updated]', e.info); // "数字员工组件刷新完成"
});

// 2. OnChatStart 事件
agentspaceChat.addEventListener(agentspaceChat.Events.OnChatStart, (e) => {
  console.log('[OnChatStart]', e.message.text);
});

// 3. OnChatEnd 事件
agentspaceChat.addEventListener(agentspaceChat.Events.OnChatEnd, (e) => {
  console.log('[OnChatEnd] 对话结束');
});

// 4. OnError 事件
agentspaceChat.addEventListener(agentspaceChat.Events.OnError, (e) => {
  console.error('[OnError]', e.error);
});

// 5. OnMessageSend 事件
agentspaceChat.addEventListener(agentspaceChat.Events.OnMessageSend, (e) => {
  console.log('[OnMessageSend]', e.message.text, e.message.sender);
});

// 6. OnMessageReceive 事件
agentspaceChat.addEventListener(agentspaceChat.Events.OnMessageReceive, (e) => {
  console.log('[OnMessageReceive]', e.message.text);
  // 处理富内容
  if (e.message.content_blocks) {
    e.message.content_blocks.forEach(block => {
      console.log('内容块:', block.title);
    });
  }
});

// 7. OnSelected 事件
agentspaceChat.addEventListener(agentspaceChat.Events.OnSelected, (e) => {
  console.log('[OnSelected]', e);
});

// 8. OnAuthRequest 事件（重要）
agentspaceChat.addEventListener(agentspaceChat.Events.OnAuthRequest, (e) => {
  console.log('[业务方 addEventListener] detailScopeUser:',e.detailScopeUser );
});
```

### 移除事件监听

```javascript
// 定义事件处理函数
const handleChatStart = (event) => {
  console.log('对话开始:', event);
};

// 添加监听
agentspaceChat.addEventListener(agentspaceChat.Events.OnChatStart, handleChatStart);

// 移除监听
agentspaceChat.removeEventListener(agentspaceChat.Events.OnChatStart, handleChatStart);
```

## 完整使用示例

> 您需要在config.js中进行配置，然后使用这个示例（React项目），依赖挂载可以参考[React项目导入](#react项目导入)

### index.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>AgentSpace Chat SDK Demo</title>
    <style>
      body {
        width: 100vw;
        height: 100vh;
        overflow: hidden;
        background-color: #f7f8f9 !important;
      }
      #agentspace-container{
        height: 100vh;
        width: 100vw;
      }
    </style>
  </head>
  <body>
    <div id="root" class="height-100vh width-100vw">
      <div id="agentspace-container" class="height-100vh width-100vw"></div>
    </div>
    
    <script src="https://qn.cache.wpscdn.cn/open_static/libs/open-sdk/0.0.2/open-sdk.0.0.2.umd.js"></script>

    <!-- 授权弹框辅助函数【此处模拟业务方的实现】 -->
    <script src="./demo/chat-demo/auth-popup-helpers.js"></script>
    <script>
      // 初始化弹窗检测器
      initPopupBlockDetector();
    </script>
    
    <!-- 配置文件 -->
    <script src="./demo/chat-demo/config.js"></script>
    
    <!-- 授权逻辑 -->
    <script src="./demo/chat-demo/auth.js"></script>
    
    <!-- 主应用逻辑 -->
    <script src="./demo/chat-demo/app.js"></script>
    
    <!-- 启动应用 -->
    <script>
      startApp()
    </script>
  </body>
</html>
```

### config.js 

```js
/**
 * 配置文件
 */

// biz-server 第三方服务地址，您需要替换为您的服务地址
const host = 'http://127.0.0.1:3000'; 

// 默认权限范围，配置您需要的权限
const defaultScopes = [
  'kso.component.agentspace_chat',
  'kso.wiki.readwrite'
];

// Agent 配置，您需要配置您的数字员工的agentid                
const agentConfig = {
  agentId: '*********',
};

// OAuth2 配置,您需要配置您的state 		
const oauthConfig = {
  appId: agentConfig.agentId,
  redirect_uri: 'https://agentspace.wps.cn/sdk-callback',
  state: 'agent-space-chat',
};

```

### auth.js

```js
/**
 * 授权相关函数
 */

// 发起授权
async function authorize(scopes) {
  // 用户授权演示
  // 以下信息，建议从业务服务端返回给前端
  OpenSDK.OAuth2.authorize({
    appId: oauthConfig.appId,
    redirect_uri: oauthConfig.redirect_uri,
    scope: scopes.join(','),
    mode: OpenSDK.OAuth2.Mode.POPUP,
    state: oauthConfig.state,
  });
}

// 暴露给全局
window.authorize = authorize;
```

### app.js

```js
/**
 * 主应用逻辑
 */

let agentspaceChatUmdLoadPromise = null;

// 加载第三方依赖
function loadScript(src) {
  return new Promise((resolve, reject) => {
    // 已存在则复用
    const existing = document.querySelector(`script[src="${src}"]`);
    if (existing) {
      if (existing.dataset.loaded === "1") return resolve();
      existing.addEventListener("load", resolve, { once: true });
      existing.addEventListener("error", reject, { once: true });
      return;
    }

    const s = document.createElement("script");
    s.src = src;
    s.async = true;
    s.addEventListener("load", () => {
      s.dataset.loaded = "1";
      resolve();
    });
    s.addEventListener("error", reject);
    document.head.appendChild(s);
  });
}

/**
 * - 加载 agentspace_chat UMD
 */
function loadAgentspaceChatUmd() {
  if (agentspaceChatUmdLoadPromise) return agentspaceChatUmdLoadPromise;

  agentspaceChatUmdLoadPromise = (async () => {

     // UMD 包在浏览器环境下会从window读取这三个依赖，需要在script加载前挂载
     (window as any).AgentSpaceWebSDKDeps = {
      React:React, // React依赖
      ReactDOM: {
        ...ReactDOM,
        createRoot:ReactDOMClient.createRoot, // React18的createRoot在react-dom/client上
      },  // ReactDOM依赖
      ReactJSXRuntime:ReactJSXRuntime,  // ReactJSXRuntime
    };
    
    await loadScript(
      "https://qn.cache.wpscdn.cn/open_static/libs/widgets/agentspace_chat/1.0.8/kso.component.agentspace_chat.1.0.8.umd.js"
    );
  })();

  return agentspaceChatUmdLoadPromise;
}


let hasRender = false;

// localStorage.scopes || 使用默认值
const savedScopes = localStorage.getItem('merged_scopes');
let scopes = savedScopes ? JSON.parse(savedScopes) : defaultScopes;

async function startApp() {
  if (hasRender) return;
  OpenSDK.setDebug(true);
  // 检查app_config是否过期
  const app_config = localStorage.getItem('app_config');
  if (app_config) {
    const {
      app_id,
      signature,
      noncestr,
      timestamp,
      tag,
      url,
      app_config_timestamp,
    } = JSON.parse(app_config);
    console.log('app_config_timestamp', Date.now() - app_config_timestamp);
    if (Date.now() - app_config_timestamp < 7200000) {
      await OpenSDK.config({
        scopes,
        signature, // 签名
        appId: app_id, // 应用 appId
        timestamp, // 时间戳（毫秒）
        nonceStr: noncestr, // 随机字符串
        tag,
        url,
      });

      // 创建组件
      renderWidget();
      return;
    } else {
      localStorage.removeItem('app_config');
      // 缓存过期，重新开始授权流程
      // console.log('[授权流程] app_config 缓存已过期，重新授权')
      window.location.reload();
    }
  }

  // 无缓存或缓存过期，开始授权
  console.log('[授权流程] 开始授权');
  authorize(scopes);

  OpenSDK.addEventListener(OpenSDK.Events.OAuth2Message, async (event) => {
    // 安全起见，判断来源
    if (event.origin !== 'https://agentspace.wps.cn') return;
    const code = event.data.code;
    console.log('OAuth2Message code', code);

    // 步骤一：拿到临时授权码code，提交给服务端申请access_token
    // 应用授权，无需走OAuth授权流程，直接申请access_token
    // 用户授权，走OAuth授权流程，获取auth_code，然后申请access_token
    if (!code) {
      authorize(scopes);
      return;
    }

    // 步骤二：获取biz-server 申请的js_ticket相关签名信息
    const { app_id, signature, noncestr, timestamp, tag, url } = await fetch(
      `${host}/api/get_app_config?code=${code}`
    ).then(async (res) => {
      const resp = await res.json();
      if (resp?.code === 0) {
        return resp.data || {};
      } else {
        throw new Error('获取signature失败');
      }
    });
    // 先删除缓存的 app_config
    localStorage.removeItem('app_config');
    // 将获取的信息缓存存储，两个小时后过期
    localStorage.setItem(
      'app_config',
      JSON.stringify({
        app_id,
        signature,
        noncestr,
        timestamp,
        tag,
        url,
        app_config_timestamp: Date.now(),
      })
    );
    // 步骤三：OpenSDK设置签名信息
    await OpenSDK.config({
      scopes,
      signature, // 签名
      appId: app_id, // 应用 appId
      timestamp, // 时间戳（毫秒）
      nonceStr: noncestr, // 随机字符串
      tag,
      url,
    });
    // 步骤四：渲染组件
    renderWidget();
  });
}

async function renderWidget() {

  // 加载 agentspace_chat UMD
  await loadAgentspaceChatUmd();
  if (OpenSDK.components.size === 0) return;

  const agentspaceChat = OpenSDK.create('kso-agentspace-chat', {
    agentId: agentConfig.agentId,
    initialExpanded: false,
  });
  agentspaceChat.mount(document.getElementById('agentspace-container'));

  hasRender = true;

  // 第三方开发者监听事件
  agentspaceChat.addEventListener(agentspaceChat.Events.Updated, (e) => {
    console.log('[3rd] Updated:', e);
  });
  agentspaceChat.addEventListener(agentspaceChat.Events.OnSelected, (e) => {
    console.log('[3rd] OnSelected:', e);
  });

  // 监听scopes范围不够，二次授权
  agentspaceChat.addEventListener(
    agentspaceChat.Events.OnAuthRequest,
    async (e) => {
      const detailScopeUser = e.detailScopeUser || []; // detail接口返回的所有required scopes

      console.log(
        '[业务方 addEventListener] detailScopeUser:',
        detailScopeUser
      );

      // 使用本地配置的scopes进行对比，判断缺失的scopes
      const missingScopes = detailScopeUser.filter(
        (scope) => !scopes.includes(scope)
      );

      // 如果没有缺失的权限，无需重新授权
      if (missingScopes.length === 0) {
        console.log('[业务方 addEventListener] 权限验证通过，无需重新授权');
        return;
      }

      // 合并 scopes
      const mergedScopes = [...new Set([...scopes, ...missingScopes])];
      scopes = mergedScopes;

      // 保存合并后的scopes
      localStorage.setItem('merged_scopes', JSON.stringify(mergedScopes));
      // 清除缓存的 app_config，强制重新获取新的授权信息
      localStorage.removeItem('app_config');

      console.log('[业务方 addEventListener] 合并后的scopes:', mergedScopes);

      // 显示自定义授权提示 UI（不使用 window.confirm）
      showAuthPrompt(
        missingScopes,
        mergedScopes,
        // 确认授权回调
        (mergedScopes) => authorize(mergedScopes),
        // 取消授权回调
        () => {
          console.log('[业务方 addEventListener] 用户取消授权');
        }
      );
    }
  );
}
```

### auth-popup-helpers.js

```js

/**
 * 授权弹框辅助函数 【此处模拟业务方的实现】
 * 包含：弹窗被阻止检测、授权提示UI等
 */

// 显示弹窗被阻止的提示
function showPopupBlockedWarning() {
  // 移除可能存在的旧提示
  const existingWarning = document.getElementById('popup-blocked-warning');
  if (existingWarning) {
    existingWarning.remove();
  }

  const warningBox = document.createElement('div');
  warningBox.id = 'popup-blocked-warning';
  warningBox.style.cssText = `
    position: fixed;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    background: #fff3cd;
    border: 1px solid #ffc107;
    color: #856404;
    padding: 15px 20px;
    border-radius: 8px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.15);
    z-index: 999999;
    max-width: 500px;
    font-size: 14px;
    animation: slideDown 0.3s ease-out;
  `;

  warningBox.innerHTML = `
    <style>
      @keyframes slideDown {
        from {
          transform: translateX(-50%) translateY(-20px);
          opacity: 0;
        }
        to {
          transform: translateX(-50%) translateY(0);
          opacity: 1;
        }
      }
    </style>
    <div style="display: flex; align-items: flex-start; gap: 12px;">
      <div style="font-size: 20px;">⚠️</div>
      <div style="flex: 1;">
        <div style="font-weight: bold; margin-bottom: 8px;">浏览器已阻止弹窗</div>
        <div style="margin-bottom: 8px;">授权窗口被浏览器拦截，请按以下步骤操作：</div>
        <ol style="margin: 8px 0 0 0; padding-left: 20px;">
          <li>点击地址栏右侧的 <strong>弹窗被阻止</strong> 图标</li>
          <li>选择 <strong>始终允许来自此网站的弹窗</strong></li>
          <li>刷新页面重新授权</li>
        </ol>
      </div>
      <button id="close-popup-warning" style="
        background: none;
        border: none;
        font-size: 18px;
        cursor: pointer;
        padding: 0;
        line-height: 1;
        color: #856404;
      ">✕</button>
    </div>
  `;

  document.body.appendChild(warningBox);

  // 关闭按钮
  document.getElementById('close-popup-warning').onclick = () => {
    warningBox.style.animation = 'slideDown 0.3s ease-out reverse';
    setTimeout(() => warningBox.remove(), 300);
  };

  // 10秒后自动关闭
  setTimeout(() => {
    if (document.getElementById('popup-blocked-warning')) {
      warningBox.style.animation = 'slideDown 0.3s ease-out reverse';
      setTimeout(() => warningBox.remove(), 300);
    }
  }, 10000);
}

// 显示授权提示 UI（不使用 window.confirm，浏览器会拦截，需要第三方实现一个弹框）
function showAuthPrompt(missingScopes, mergedScopes, onConfirm, onCancel) {
  // 移除可能存在的旧提示
  const existingPrompt = document.getElementById('auth-prompt-box');
  if (existingPrompt) {
    existingPrompt.remove();
  }

  // 提示框
  const promptBox = document.createElement('div');
  promptBox.id = 'auth-prompt-box';
  promptBox.style.cssText = `
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background: white;
    padding: 20px;
    border-radius: 4px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.3);
    z-index: 99999;
    min-width: 300px;
  `;

  // 内容
  promptBox.innerHTML = `
    <div style="margin-bottom: 15px;">
      <strong>需要新增权限</strong>
    </div>
    <div style="margin-bottom: 15px; font-size: 14px; color: #666;">
      缺失权限：${missingScopes.join(', ')}
    </div>
    <div style="text-align: right;">
      <button id="auth-cancel-btn" style="margin-right: 10px; padding: 6px 15px; cursor: pointer;">取消</button>
      <button id="auth-confirm-btn" style="padding: 6px 15px; background: #1890ff; color: white; border: none; cursor: pointer;">授权</button>
    </div>
  `;
  document.body.appendChild(promptBox);

  // 按钮事件
  document.getElementById('auth-confirm-btn').onclick = () => {
    console.log('[授权提示] 用户点击授权');
    document.body.removeChild(promptBox);
    if (typeof onConfirm === 'function') {
      onConfirm(mergedScopes);
    }
  };

  document.getElementById('auth-cancel-btn').onclick = () => {
    console.log('[授权提示] 用户取消');
    document.body.removeChild(promptBox);
    if (typeof onCancel === 'function') {
      onCancel();
    }
  };
}

// 初始化弹窗检测器（拦截 window.open）
function initPopupBlockDetector() {
  const originalWindowOpen = window.open;

  window.open = function (...args) {
    const popup = originalWindowOpen.apply(this, args);

    // 检测弹窗是否被阻止
    if (!popup || popup.closed || typeof popup.closed === 'undefined') {
      console.error('[弹窗检测] 浏览器阻止了弹窗');
      showPopupBlockedWarning();
      return null;
    }

    return popup;
  };
}

// 导出到全局
window.showPopupBlockedWarning = showPopupBlockedWarning;
window.showAuthPrompt = showAuthPrompt;
window.initPopupBlockDetector = initPopupBlockDetector;
```

### 后端接口

见 [后端接口实现](#后端api接口示例)

## 使用指南

### 传参说明 
```typescript
{
  agentId:string; // 组件使用的智能体ID---必选参数
  welcomeMessage:string;  // 组件欢迎页显示文案
  className:string; // 组件最外层自定义样式
  agentChatClassName:IAgentChatClassNameType;  // 组件定制区域自定义样式
  initialExpanded:boolean;  // 是否初始化展开组件，默认为false
  floatingButtonTips:string;  // 最小化状态下的tooltip文案
  locale:ILocaleType;  // 组件语言配置
  presetQuestions:string[]; // 欢迎页默认问题配置
  agentExtraButtons:IExtraButton[]; // AI消息自定义按钮配置
  userExtraButtons:IExtraButton[];  // 用户消息自定义按钮配置
  onChatMessageChannel:string;  // 对话消息监听的唯一频道号
  customAiMessageComponenent:ReactNode; // 自定义AI消息组件
  onAgentChatClose:function(); // 组件关闭事件回调
  hideTitleActions:boolean; // 是否隐藏标题栏右侧操作按钮，默认为false
  hideMinimizeTooltip:boolean;  // 是否隐藏最小化状态下的tooltip显示，默认为false
  shouldShowUploadButton:boolean; // 是否显示知识库上传按钮，默认为true
}
```

```typescript
interface IAgentChatClassNameType {
  chatTitle?:string; // 自定义头部样式，选填
  chatWelcome?:string;  // 自定义欢迎页样式，选填
  chatQuestion?:string; // 自定义默认问题样式，选填
  chatAction?:string; // 自定义快捷指令样式，选填
  chatActionMore?:string; // 自定义快捷指令“更多”，单个按钮样式，选填
  chatInput?:string;  // 自定义输入框样式，选填
}

enum ILocaleType {
  // 中文简体变体
  zh: 'zh',
  'zh-cn': 'zh',
  zh_cn: 'zh',
  chinese: 'zh',
  chs: 'zh',
  'zh-hans': 'zh',
  // 中文繁体变体
  tw: 'tw',
  'zh-tw': 'tw',
  zh_tw: 'tw',
  'zh-hk': 'tw',
  'zh-mo': 'tw',
  'zh-hant': 'tw',
  taiwanese: 'tw',
  cht: 'tw',
  // 英文变体
  en: 'en',
  'en-us': 'en',
  en_us: 'en',
  'en-gb': 'en',
  'en-au': 'en',
  'en-ca': 'en',
  english: 'en',
  eng: 'en',
}

interface IExtraButton {
  element:ReactNode;  // 自定义按钮组件，必填参数
  className?:string;  // 自定义按钮包裹层自定义样式，选填
  title?:string;  // 自定义按钮标题，选填
  text?:string; // 自定义按钮文本，选填
  action?: (data: ImessageData) => void;  // 自定义按钮回调事件，选填
}

interface ImessageData {
  message_id:string; // 消息唯一ID
}
```
### 代码示例
#### 修改欢迎页配置
```javascript
const AgentChat = OpenSDK.create('kso-agentspace-chat', {
    agentId: "×××××××××", // 填入需要使用的智能体ID
    welcomeMessage:"欢迎来到对话页欢迎页界面，请开始你的操作."  // 修改欢迎页文案
    presetQuestions:["默认问题1","自定义问题2","test3"]  // 修改欢迎页默认问题
  });
AgentChat.mount(document.getElementById('agentspace-container')); // 组件渲染
```
![welcome](https://cloudcdn.qwps.cn/open/_img/85c3620878.png)
#### 修改组件语言配置
```javascript
const AgentChat = OpenSDK.create('kso-agentspace-chat', {
    agentId: "×××××××××", // 填入需要使用的智能体ID
    locale:"zh-tw" // 切换为中文繁体
  });
AgentChat.mount(document.getElementById('agentspace-container')); // 组件渲染
```
#### 隐藏顶部操作按钮、最小化tooltip
```javascript
const AgentChat = OpenSDK.create('kso-agentspace-chat', {
    agentId: "×××××××××", // 填入需要使用的智能体ID
    hideTitleActions:true,  // 隐藏顶部操作按钮
    hideMinimizeTooltip:true, //  隐藏最小化tooltip显示
  });
AgentChat.mount(document.getElementById('agentspace-container')); // 组件渲染
```
#### 添加关闭回调事件
```javascript
const AgentChat = OpenSDK.create('kso-agentspace-chat', {
    agentId: "×××××××××", // 填入需要使用的智能体ID
    onAgentChatClose:() => {
      // 自定义关闭事件
      console.log("agentspace-chat关闭!");
    }
  });
AgentChat.mount(document.getElementById('agentspace-container')); // 组件渲染
```
#### 自定义组件样式
```javascript
const AgentChat = OpenSDK.create('kso-agentspace-chat', {
    agentId: "×××××××××", // 填入需要使用的智能体ID
    className:"p-4 m-4", // 组件整体自定义样式
    agentChatClassName:{
      chatTitle:'bg-white',
      chatWelcome:'mt-4',
      chatQuestion:'border rounded-md p-2 bg-[#f5f5f5] border-green-200',
      chatAction:'bg-whitet border rounded-md p-2 border-red-200 hover:bg-gray-200',
      chatActionMore:'border rounded-md p-2 border-red-500',
      chatInput:'text-red-500'
    }, //  组件区域自定义样式
  });
AgentChat.mount(document.getElementById('agentspace-container')); // 组件渲染
```
![className](https://cloudcdn.qwps.cn/open/_img/790d78e150.png)
#### 添加自定义按钮及回调事件
```javascript
const AgentChat = OpenSDK.create('kso-agentspace-chat', {
    agentId: "×××××××××", // 填入需要使用的智能体ID
    agentExtraButtons: [
      {
        element: <TestCustomButtonAgent />, // 自定义AI消息按钮组件
        className:'flex gap-[6px] rounded-md border-none px-1 py-1 text-xs font-normal text-[#000] hover:bg-[#f4f4f5]',
        action:(data: { message_id: string }) => {
          console.log('智能体测试按钮被点击', data);
        },
      },
    ], // 添加AI消息自定义按钮及回调事件
    userExtraButtons: [
      {
        element: <TestCustomButtonUser />,  // 自定义用户消息按钮组件
        className:'flex gap-[6px] rounded-md border-none px-1 py-1 text-xs font-normal text-[#000] hover:bg-[#f4f4f5]',
        action:(data: { message_id: string }) => {
          console.log('用户测试按钮被点击', data);
        },
      },
    ],
  });
AgentChat.mount(document.getElementById('agentspace-container')); // 组件渲染
```
![button](https://cloudcdn.qwps.cn/open/_img/58bf5e05fb.png)
#### 自定义AI消息组件渲染及对话数据获取
传入的自定义AI消息组件必须包含可接收message和onChatMessage的props，用于消息数据的获取和对话流式数据的监听
```typescript
/**
 * 自定义 AI 消息组件 props定义
 */
interface ICustomAiMessageProps {
  message?: IMessage; // 会话消息数据
  onChatMessage?: (callback: (data: IChatMessage) => void) => () => void; // 对话数据监听
  // 其他props
  ......
}

/**
 * 自定义 AI 消息组件示例
 */
import { useState, useEffect } from 'react';
import type { IMessage, IChatMessage } from '@/types';

const TestCustomAiMessageComponent: React.FC<ICustomAiMessageProps> = ({
  message,
  onChatMessage,
}) => {
  const [streamContent, setStreamContent] = useState<string>('');

  // 监听流式消息
  useEffect(() => {
    if (!onChatMessage || message?.category === 'message') return;

    const unsubscribe = onChatMessage((data: IChatMessage) => {
      if (data.type === 'answer') {
        // 完整消息
        setStreamContent(data.content || '');
      } else if (data.type === 'answer_chunk') {
        // 流式片段
        setStreamContent((prev) => prev + (data.content_chunk || ''));
      }
    });

    return unsubscribe;
  }, [onChatMessage, message?.category]);

  // 决定显示内容
  const displayText = message?.category === 'message' 
    ? message?.text || '' 
    : streamContent || message?.text || '';

  return (
    <div
      style={{
        borderRadius: '6px',
        padding: '8px 12px',
        maxWidth: '100%',
        display: 'inline-block',
        whiteSpace: 'pre-wrap',
        wordBreak: 'break-word',
        color: '#2563eb',
        backgroundColor: '#eff6ff',
        border: '1px solid #bfdbfe',
        fontSize: '14px',
        lineHeight: '1.6',
      }}
    >
      {displayText}
    </div>
  );
};

export default TestCustomAiMessageComponent;


```
关键类型定义
```typescript
// 事件数据类型定义
interface EventMessage {
  type: string;
  content: string;
  content_type: string;
  seq_id: number;
  step: number;
  parent_step: number;
  timestamp: string;
  run_id: string;
  extra_info?: Record<string, any>;
};

// 工具类型定义
interface Tools {
  id: string;
  name: string;
  version: string;
  display_name: string;
}

// 消息数据类型定义
interface IMessage {
  chat_id?: string; // 聊天会话ID
  flow_id: string;
  text: string; // 消息文本内容
  sender: string;
  sender_name: string;
  session_id: string; // 会话ID
  timestamp: string;
  files: Array<string>;
  id: string;
  frontend_id: string;
  edit: boolean;
  background_color: string;
  text_color: string;
  category?: string;
  properties?: any;
  content_blocks?: ContentBlock[];  // 内容块列表（结构化内容展示）
  event_messages?: EventMessage[];  // 消息相关事件列表
  wps_tools: Tools[]; // WPS 工具调用列表
  role?: string;
  knowledges?: string;
  isError?: boolean;
};

// 对话数据类型定义
interface IChatMessage {
  role: 'user' | 'assistant'; // 对话ID
  type: string;
  run_id: string;
  content: string;
  content_chunk: string;
  content_type: string;
  seq_id: number;
  step: number;
  parent_step: number;
  chat_id: string;
  session_id: string;
  timestamp: string;
  extra_info: Record<string, any>;
  message_id: string;
}

```
自定义AI消息组件消息数据获取和渲染说明
```typescript
const TestCustomAiMessageComponent: React.FC<ICustomAiMessageProps> = ({
  message,
  onChatMessage,
}) => {
  // 其他状态处理和函数

  // 监听流式消息
  useEffect(() => {
    const unsubscribe = onChatMessage((data: IChatMessage) => {
       // 处理流式数据，固定判断为 message?.category ==='message'，表达式为false时该组件在对话中
      if(message?.category ==='message') return;
      if (data.type === 'answer') {
        // 完整消息
        setStreamContent(data.content || '');
      } else if (data.type === 'answer_chunk') {
        // 流式片段
        setStreamContent((prev) => prev + (data.content_chunk || ''));
      }
    });

    return unsubscribe;
  }, [onChatMessage, message?.category]);

  // 根据固定判断 message?.category === 'message'和自定义状态进行选择，可以选择渲染消息历史数据，或者使用自定义数据进行渲染
  // 示例：处于对话状态中时渲染自定义状态的数据，否则渲染历史消息数据
  const displayText = message?.category === 'message' 
    ? message?.text || '' 
    : streamContent || message?.text || '';

  return (
    <div
      style={{
        borderRadius: '6px',
        padding: '8px 12px',
        maxWidth: '100%',
        display: 'inline-block',
        whiteSpace: 'pre-wrap',
        wordBreak: 'break-word',
        color: '#2563eb',
        backgroundColor: '#eff6ff',
        border: '1px solid #bfdbfe',
        fontSize: '14px',
        lineHeight: '1.6',
      }}
    >
      {displayText}
    </div>
  );
};

```
初始化定义及组件渲染
```javascript
const AgentChat = OpenSDK.create('kso-agentspace-chat', {
    agentId: "×××××××××", // 填入需要使用的智能体ID
    onChatMessageChannel: 'test-chat-message-channel',  // 设置监听频道号，唯一字符串即可
    customAiMessageComponent: <TestCustomAiMessageComponent />,  // 传入自定义的AI消息组件
  });
AgentChat.mount(document.getElementById('agentspace-container')); // 组件渲染
```
![message](https://cloudcdn.qwps.cn/open/_img/615fc015b2.png)
