├── .env.sample ├── .github └── workflows │ └── static.yml ├── .gitignore ├── LICENSE ├── README.md ├── README_CN.md ├── docs ├── .vitepress │ └── config.mts ├── CNAME ├── api-examples.md ├── get-started.md └── index.md ├── eslint.config.mjs ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── src ├── adapter │ ├── coze │ │ └── index.ts │ └── openai │ │ ├── index.test.ts │ │ └── index.ts ├── bot │ └── index.ts ├── flow │ ├── index.test.ts │ └── index.ts ├── index.test.ts ├── index.ts ├── parser │ ├── html.parser.ts │ ├── html.test.ts │ ├── index.ts │ ├── json.parser.ts │ └── json.test.ts ├── tube │ ├── index.test.ts │ └── index.ts ├── types.ts └── utils │ └── index.ts ├── test ├── server.ts └── test.ts ├── tsconfig-cjs.json └── tsconfig.json /.env.sample: -------------------------------------------------------------------------------- 1 | # For Jest 2 | 3 | MODEL_NAME=deepseek-chat 4 | ENDPOINT=https://api.deepseek.com/v1 5 | API_KEY= -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a VitePress site to GitHub Pages 2 | name: Deploy VitePress site to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the `main` branch. Change this to `master` if you're 6 | # using the `master` branch as the default branch. 7 | push: 8 | branches: [main] 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | 19 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 20 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 21 | concurrency: 22 | group: pages 23 | cancel-in-progress: false 24 | 25 | jobs: 26 | # Build job 27 | build: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 # Not needed if lastUpdated is not enabled 34 | - uses: pnpm/action-setup@v3 # Uncomment this if you're using pnpm 35 | with: 36 | version: 8 37 | # - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun 38 | - name: Setup Node 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: 20 42 | cache: pnpm # or npm / yarn 43 | - name: Setup Pages 44 | uses: actions/configure-pages@v4 45 | - name: Install dependencies 46 | run: pnpm install # or pnpm install / yarn install / bun install 47 | - name: Build with VitePress 48 | run: pnpm docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build 49 | - name: Upload artifact 50 | uses: actions/upload-pages-artifact@v3 51 | with: 52 | path: docs/.vitepress/dist 53 | 54 | # Deployment job 55 | deploy: 56 | environment: 57 | name: github-pages 58 | url: ${{ steps.deployment.outputs.page_url }} 59 | needs: build 60 | runs-on: ubuntu-latest 61 | name: Deploy 62 | steps: 63 | - name: Deploy to GitHub Pages 64 | id: deployment 65 | uses: actions/deploy-pages@v4 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | lib 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # Jest 28 | coverage 29 | .env 30 | 31 | # VitePress 32 | cache -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Bearbobo inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ling (灵) 2 | 3 | **Ling** is a workflow framework that supports streaming of structured content generated by large language models (LLMs). It enables quick responses to content streams produced by agents or bots within the workflow, thereby reducing waiting times. 4 | 5 | ![ling workflow](https://github.com/user-attachments/assets/e105c4d5-8a8d-4049-bf2f-f3f30d6d5d5c) 6 | 7 | ## Core Features 8 | 9 | - [x] Supports data stream output via [JSONL](https://jsonlines.org/) protocol 10 | - [x] Automatic correction of token errors in JSON output 11 | - [x] Supports complex asynchronous workflows with multiple agents/bots 12 | - [x] Supports status messages during streaming output 13 | - [x] Supports [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) 14 | - [x] HTML and JSON parsers for efficient stream processing 15 | - [x] Compatible with OpenAI and other LLM providers 16 | - [ ] Provides Client SDK 17 | 18 | ## Introduction 19 | 20 | Complex AI workflows, such as those found in [Bearbobo Learning Companion](https://bearbobo.com/), require multiple agents/bots to process structured data collaboratively. However, considering real-time responses, utilizing structured data outputs is not conducive to enhancing timeliness through a streaming interface. 21 | 22 | The commonly used JSON data format, although flexible, has structural integrity, meaning it is difficult to parse correctly until all the content is completely outputted. Of course, other structured data formats like YAML can be adopted, but they are not as powerful and convenient as JSON. 23 | Ling is a streaming framework created to address this issue. Its core is a real-time converter that can parse incoming JSON data streams character by character, outputting content in the form of [jsonuri](https://github.com/aligay/jsonuri). 24 | 25 | For example, consider the following JSON format: 26 | 27 | ```json 28 | { 29 | "outline": [ 30 | { 31 | "topic": "What are clouds made of?" 32 | }, 33 | { 34 | "topic": "Why do clouds look soft?" 35 | } 36 | ] 37 | // ... 38 | } 39 | ``` 40 | 41 | During streaming input, the content may be converted in real-time into the following data outputs (using Server-sent Events): 42 | 43 | ``` 44 | data: {"uri": "outline/0/topic", "delta": "clo"} 45 | data: {"uri": "outline/0/topic", "delta": "uds"} 46 | data: {"uri": "outline/0/topic", "delta": "are"} 47 | data: {"uri": "outline/0/topic", "delta": "mad"} 48 | data: {"uri": "outline/0/topic", "delta": "e"} 49 | data: {"uri": "outline/0/topic", "delta": "of"} 50 | data: {"uri": "outline/0/topic", "delta": "?"} 51 | data: {"uri": "outline/1/topic", "delta": "Why"} 52 | data: {"uri": "outline/1/topic", "delta": "do"} 53 | data: {"uri": "outline/1/topic", "delta": "clo"} 54 | data: {"uri": "outline/1/topic", "delta": "uds"} 55 | data: {"uri": "outline/1/topic", "delta": "loo"} 56 | data: {"uri": "outline/1/topic", "delta": "k"} 57 | data: {"uri": "outline/1/topic", "delta": "sof"} 58 | data: {"uri": "outline/1/topic", "delta": "t"} 59 | data: {"uri": "outline/1/topic", "delta": "?"} 60 | ... 61 | ``` 62 | 63 | This method of real-time data transmission facilitates immediate front-end processing and enables responsive UI updates. 64 | 65 | ## Installation 66 | 67 | ```bash 68 | npm install @bearbobo/ling 69 | # or 70 | pnpm add @bearbobo/ling 71 | # or 72 | yarn add @bearbobo/ling 73 | ``` 74 | 75 | ## Supported Models 76 | 77 | Ling supports various LLM providers and models: 78 | 79 | - OpenAI: GPT-4, GPT-4-Turbo, GPT-4o, GPT-3.5-Turbo 80 | - Moonshot: moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k 81 | - Deepseek 82 | - Qwen: qwen-max-longcontext, qwen-long 83 | - Yi: yi-medium 84 | 85 | ## Demo 86 | 87 | Server Example: 88 | 89 | ```js 90 | import 'dotenv/config'; 91 | 92 | import express from 'express'; 93 | import bodyParser from 'body-parser'; 94 | import cors from 'cors'; 95 | 96 | import { Ling } from "@bearbobo/ling"; 97 | import type { ChatConfig } from "@bearbobo/ling/types"; 98 | 99 | import { pipeline } from 'node:stream/promises'; 100 | 101 | const apiKey = process.env.API_KEY as string; 102 | const model_name = process.env.MODEL_NAME as string; 103 | const endpoint = process.env.ENDPOINT as string; 104 | 105 | const app = express(); 106 | 107 | app.use(cors()); 108 | app.use(bodyParser.json()); 109 | 110 | const port = 3000; 111 | 112 | app.get('/', (req, res) => { 113 | res.send('Hello World!'); 114 | }); 115 | 116 | app.post('/api', async (req, res) => { 117 | const question = req.body.question; 118 | 119 | const config: ChatConfig = { 120 | model_name, 121 | api_key: apiKey, 122 | endpoint: endpoint, 123 | }; 124 | 125 | // ------- The work flow start -------- 126 | const ling = new Ling(config); 127 | const bot = ling.createBot(/*'bearbobo'*/); 128 | bot.addPrompt('Respond to me in JSON format, starting with {.\n[Example]\n{"answer": "My response"}'); 129 | bot.chat(question); 130 | bot.on('string-response', ({uri, delta}) => { 131 | // Infer the content of the string in the JSON, and send the content of the 'answer' field to the second bot. 132 | console.log('bot string-response', uri, delta); 133 | 134 | const bot2 = ling.createBot(/*'bearbobo'*/); 135 | bot2.addPrompt(`Expand the content I gave you into more detailed content, answer me in JSON format, place the detailed answer text in the 'details' field, and place 2-3 related knowledge points in the 'related_question' field.\n[Example]\n{"details": "My detailed answer", "related_question": [...]}`); 136 | bot2.chat(delta); 137 | bot2.on('response', (content) => { 138 | // Stream data push completed. 139 | console.log('bot2 response finished', content); 140 | }); 141 | 142 | const bot3 = ling.createBot(); 143 | bot3.addPrompt('Expand the content I gave you into more detailed content, using Chinese. answer me in JSON format, place the detailed answer in Chinese in the 'details' field.\n[Example]\n{"details_cn": "my answer..."}'); 144 | bot3.chat(delta); 145 | bot3.on('response', (content) => { 146 | // Stream data push completed. 147 | console.log('bot3 response finished', content); 148 | }); 149 | }); 150 | ling.close(); // It can be directly closed, and when closing, it checks whether the status of all bots has been finished. 151 | // ------- The work flow end -------- 152 | 153 | // setting below headers for Streaming the data 154 | res.writeHead(200, { 155 | 'Content-Type': "text/event-stream", 156 | 'Cache-Control': "no-cache", 157 | 'Connection': "keep-alive" 158 | }); 159 | 160 | console.log(ling.stream); 161 | 162 | pipeline((ling.stream as any), res); 163 | }); 164 | 165 | app.listen(port, () => { 166 | console.log(`Example app listening at http://localhost:${port}`); 167 | }); 168 | ``` 169 | 170 | Client 171 | 172 | ```vue 173 | 223 | 224 | 231 | ``` 232 | 233 | ## Bot Events 234 | 235 | ### string-response 236 | 237 | This event is triggered when a string field in the JSON output by the AI is completed, returning a [jsonuri](https://github.com/aligay/jsonuri) object. 238 | 239 | ### inference-done 240 | 241 | This event is triggered when the AI has completed its current inference, returning the complete output content. At this point, streaming output may not have ended, and data continues to be sent to the front end. 242 | 243 | ### response 244 | 245 | This event is triggered when all data generated by the AI during this session has been sent to the front end. 246 | 247 | > Note: Typically, the `string-response` event occurs before `inference-done`, which in turn occurs before `response`. 248 | 249 | ## Custom Event 250 | 251 | Sometimes, we might want to send custom events to the front end to update its status. On the server, you can use `ling.sendEvent({event, data})` to push messages to the front end. The front end can then receive and process JSON objects `{event, data}` from the stream. 252 | 253 | ```js 254 | bot.on('inference-done', () => { 255 | bot.sendEvent({event: 'inference-done', state: 'Outline generated!'}); 256 | }); 257 | ``` 258 | 259 | Alternatively, you can also directly push jsonuri status updates, making it easier for the front end to set directly. 260 | 261 | ```js 262 | bot.on('inference-done', () => { 263 | bot.sendEvent({uri: 'state/outline', delta: true}); 264 | }); 265 | ``` 266 | 267 | ## Server-sent Events 268 | 269 | You can force ling to response the Server-Sent Events data format by using `ling.setSSE(true)`. This allows the front end to handle the data using the EventSource API. 270 | 271 | ```js 272 | const es = new EventSource('http://localhost:3000/?question=Can I laid on the cloud?'); 273 | 274 | es.onmessage = (e) => { 275 | console.log(e.data); 276 | } 277 | 278 | es.onopen = () => { 279 | console.log('Connecting'); 280 | } 281 | 282 | es.onerror = (e) => { 283 | console.log(e); 284 | } 285 | ``` 286 | 287 | ## Basic Usage 288 | 289 | ```typescript 290 | import { Ling, ChatConfig, ChatOptions } from '@bearbobo/ling'; 291 | 292 | // Configure LLM provider 293 | const config: ChatConfig = { 294 | model_name: 'gpt-4-turbo', // or any other supported model 295 | api_key: 'your-api-key', 296 | endpoint: 'https://api.openai.com/v1/chat/completions', 297 | sse: true // Enable Server-Sent Events 298 | }; 299 | 300 | // Optional settings 301 | const options: ChatOptions = { 302 | temperature: 0.7, 303 | max_tokens: 2000 304 | }; 305 | 306 | // Create Ling instance 307 | const ling = new Ling(config, options); 308 | 309 | // Create a bot for chat 310 | const bot = ling.createBot(); 311 | 312 | // Add system prompt 313 | bot.addPrompt('You are a helpful assistant.'); 314 | 315 | // Handle streaming response 316 | ling.on('message', (message) => { 317 | console.log('Received message:', message); 318 | }); 319 | 320 | // Handle completion event 321 | ling.on('finished', () => { 322 | console.log('Chat completed'); 323 | }); 324 | 325 | // Handle bot's response 326 | bot.on('string-response', (content) => { 327 | console.log('Bot response:', content); 328 | }); 329 | 330 | // Start chat with user message 331 | await bot.chat('Tell me about cloud computing.'); 332 | 333 | // Close the connection when done 334 | await ling.close(); 335 | ``` 336 | 337 | ## API Reference 338 | 339 | ### Ling Class 340 | 341 | The main class for managing LLM interactions and workflows. 342 | 343 | ```typescript 344 | new Ling(config: ChatConfig, options?: ChatOptions) 345 | ``` 346 | 347 | #### Methods 348 | 349 | - `createBot(root?: string | null, config?: Partial, options?: Partial)`: Creates a new ChatBot instance 350 | - `addBot(bot: Bot)`: Adds an existing Bot to the workflow 351 | - `setCustomParams(params: Record)`: Sets custom parameters for template rendering 352 | - `setSSE(sse: boolean)`: Enables or disables Server-Sent Events 353 | - `close()`: Closes all connections and waits for bots to finish 354 | - `cancel()`: Cancels all ongoing operations 355 | - `sendEvent(event: any)`: Sends a custom event through the tube 356 | 357 | #### Properties 358 | 359 | - `tube`: Gets the underlying Tube instance 360 | - `model`: Gets the model name 361 | - `stream`: Gets the ReadableStream 362 | - `id`: Gets the session ID 363 | 364 | #### Events 365 | 366 | - `message`: Emitted when a message is received 367 | - `finished`: Emitted when all operations are finished 368 | - `canceled`: Emitted when operations are canceled 369 | - `inference-done`: Emitted when a bot completes inference 370 | 371 | ### ChatBot Class 372 | 373 | Handles individual chat interactions with LLMs. 374 | 375 | ```typescript 376 | new ChatBot(tube: Tube, config: ChatConfig, options?: ChatOptions) 377 | ``` 378 | 379 | #### Methods 380 | 381 | - `addPrompt(promptTpl: string, promptData?: Record)`: Adds a system prompt with template support 382 | - `setPrompt(promptTpl: string, promptData?: Record)`: Sets a single system prompt 383 | - `addHistory(messages: ChatCompletionMessageParam[])`: Adds message history 384 | - `setHistory(messages: ChatCompletionMessageParam[])`: Sets message history 385 | - `addFilter(filter: ((data: any) => boolean) | string | RegExp | FilterMap)`: Adds a filter for messages 386 | - `clearFilters()`: Clears all filters 387 | - `chat(message: string | ChatCompletionContentPart[])`: Starts a chat with the given message 388 | - `finish()`: Marks the bot as finished 389 | 390 | #### Events 391 | 392 | - `string-response`: Emitted for text responses 393 | - `object-response`: Emitted for object responses 394 | - `inference-done`: Emitted when inference is complete 395 | - `response`: Emitted when the full response is complete 396 | - `error`: Emitted on errors 397 | 398 | ### ChatConfig 399 | 400 | ```typescript 401 | interface ChatConfig { 402 | model_name: string; // LLM model name 403 | endpoint: string; // API endpoint 404 | api_key: string; // API key 405 | api_version?: string; // API version (for some providers) 406 | session_id?: string; // Custom session ID 407 | max_tokens?: number; // Maximum tokens to generate 408 | sse?: boolean; // Enable Server-Sent Events 409 | } 410 | ``` 411 | 412 | ### ChatOptions 413 | 414 | ```typescript 415 | interface ChatOptions { 416 | temperature?: number; // Controls randomness (0-1) 417 | presence_penalty?: number; // Penalizes repetition 418 | frequency_penalty?: number; // Penalizes frequency 419 | stop?: string[]; // Stop sequences 420 | top_p?: number; // Nucleus sampling parameter 421 | response_format?: any; // Response format settings 422 | max_tokens?: number; // Maximum tokens to generate 423 | quiet?: boolean; // Suppress output 424 | bot_id?: string; // Custom bot ID 425 | } 426 | ``` 427 | 428 | ## Contributing 429 | 430 | Contributions are welcome! Please feel free to submit a Pull Request. 431 | 432 | 1. Fork the repository 433 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 434 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 435 | 4. Push to the branch (`git push origin feature/amazing-feature`) 436 | 5. Open a Pull Request 437 | 438 | ## License 439 | 440 | This project is licensed under the Apache License - see the LICENSE file for details. 441 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # Ling (灵) 2 | 3 | Ling (灵) 是一个支持 LLM 流式输出(Streaming)的工作流框架,能够快速响应代理或机器人在工作流中产生的内容流,从而减少等待时间。 4 | 5 | ## 核心特性 6 | 7 | - [x] 支持 [JSONL](https://jsonlines.org/) 协议的数据流输出 8 | - [x] JSON 的 TOKEN 异常的自动修复 9 | - [x] 支持多个代理/机器人协作的复杂异步工作流 10 | - [x] 支持流式输出过程中的状态消息 11 | - [x] 支持 [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) 12 | - [x] 高效的 HTML 和 JSON 流解析器 13 | - [x] 兼容 OpenAI 和其他 LLM 提供商 14 | - [ ] 提供客户端 SDK 15 | 16 | ## 介绍 17 | 18 | 一些复杂的AI工作流,如[波波熊学伴](https://bearbobo.com/),需要多个 Agent/Bot 协同处理结构化数据。但是,考虑实时响应,采用结构化数据输出,不利于使用流式接口提升时效性。 19 | 因为常用的 JSON 数据格式虽然灵活,但是它的结构具有完整性,也就是说,在所有内容输出完整之前,我们很难将其正确解析出来。当然我们可以采用其他一些结构数据格式,例如yaml,但是又不如 JSON 格式强大和方便。 20 | 21 | Ling 正是为了解决这个问题而诞生的流式框架,它的核心是一个实时转换器,可以将输入的 JSON 数据流,一个字符一个字符地进行解析,将内容以 [jsonuri](https://github.com/aligay/jsonuri) 的方式输出。 22 | 23 | 例如,以下JSON格式: 24 | 25 | ```json 26 | { 27 | "outline": [ 28 | { 29 | "topic": "云朵是由什么构成的?" 30 | }, 31 | { 32 | "topic": "为什么云朵看起来软软的?" 33 | } 34 | ] 35 | // ... 36 | } 37 | ``` 38 | 39 | 在流式输入时,内容依次被实时转换成如下数据输出(使用 Server-sent Events): 40 | 41 | ``` 42 | data: {"uri": "outline/0/topic", "delta": "云"} 43 | data: {"uri": "outline/0/topic", "delta": "朵"} 44 | data: {"uri": "outline/0/topic", "delta": "是"} 45 | data: {"uri": "outline/0/topic", "delta": "由"} 46 | data: {"uri": "outline/0/topic", "delta": "什"} 47 | data: {"uri": "outline/0/topic", "delta": "么"} 48 | data: {"uri": "outline/0/topic", "delta": "构"} 49 | data: {"uri": "outline/0/topic", "delta": "成"} 50 | data: {"uri": "outline/0/topic", "delta": "的"} 51 | data: {"uri": "outline/0/topic", "delta": "?"} 52 | data: {"uri": "outline/1/topic", "delta": "为"} 53 | data: {"uri": "outline/1/topic", "delta": "什"} 54 | data: {"uri": "outline/1/topic", "delta": "么"} 55 | data: {"uri": "outline/1/topic", "delta": "云"} 56 | data: {"uri": "outline/1/topic", "delta": "朵"} 57 | data: {"uri": "outline/1/topic", "delta": "看"} 58 | data: {"uri": "outline/1/topic", "delta": "起"} 59 | data: {"uri": "outline/1/topic", "delta": "来"} 60 | data: {"uri": "outline/1/topic", "delta": "软"} 61 | data: {"uri": "outline/1/topic", "delta": "软"} 62 | data: {"uri": "outline/1/topic", "delta": "的"} 63 | data: {"uri": "outline/1/topic", "delta": "?"} 64 | ``` 65 | 66 | 这样实时发送的数据,就方便了前端立即处理,并实现响应式的 UI 更新。 67 | 68 | ## 安装 69 | 70 | ```bash 71 | npm install @bearbobo/ling 72 | # 或 73 | pnpm add @bearbobo/ling 74 | # 或 75 | yarn add @bearbobo/ling 76 | ``` 77 | 78 | ## 支持的模型 79 | 80 | Ling 支持多种 LLM 提供商和模型: 81 | 82 | - OpenAI: GPT-4, GPT-4-Turbo, GPT-4o, GPT-3.5-Turbo 83 | - Moonshot: moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k 84 | - Deepseek 85 | - Qwen: qwen-max-longcontext, qwen-long 86 | - Yi: yi-medium 87 | 88 | ## 示例 89 | 90 | 服务端示例: 91 | 92 | ```js 93 | import 'dotenv/config'; 94 | 95 | import express from 'express'; 96 | import bodyParser from 'body-parser'; 97 | import cors from 'cors'; 98 | 99 | import { Ling } from "@bearbobo/ling"; 100 | import type { ChatConfig } from "@bearbobo/ling/types"; 101 | 102 | import { pipeline } from 'node:stream/promises'; 103 | 104 | const apiKey = process.env.API_KEY as string; 105 | const model_name = process.env.MODEL_NAME as string; 106 | const endpoint = process.env.ENDPOINT as string; 107 | 108 | const app = express(); 109 | 110 | app.use(cors()); 111 | app.use(bodyParser.json()); 112 | 113 | const port = 3000; 114 | 115 | app.get('/', (req, res) => { 116 | res.send('Hello World!'); 117 | }); 118 | 119 | app.post('/api', async (req, res) => { 120 | const question = req.body.question; 121 | 122 | const config: ChatConfig = { 123 | model_name, 124 | api_key: apiKey, 125 | endpoint: endpoint, 126 | }; 127 | 128 | // ------- The work flow start -------- 129 | const ling = new Ling(config); 130 | const bot = ling.createBot(/*'bearbobo'*/); 131 | bot.addPrompt('Respond to me in JSON format, starting with {.\n[Example]\n{"answer": "My response"}'); 132 | bot.chat(question); 133 | bot.on('string-response', ({uri, delta}) => { 134 | // Infer the content of the string in the JSON, and send the content of the 'answer' field to the second bot. 135 | console.log('bot string-response', uri, delta); 136 | 137 | const bot2 = ling.createBot(/*'bearbobo'*/); 138 | bot2.addPrompt(`Expand the content I gave you into more detailed content, answer me in JSON format, place the detailed answer text in the 'details' field, and place 2-3 related knowledge points in the 'related_question' field.\n[Example]\n{"details": "My detailed answer", "related_question": [...]}`); 139 | bot2.chat(delta); 140 | bot2.on('response', (content) => { 141 | // Stream data push completed. 142 | console.log('bot2 response finished', content); 143 | }); 144 | 145 | const bot3 = ling.createBot(); 146 | bot3.addPrompt('Expand the content I gave you into more detailed content, using Chinese. answer me in JSON format, place the detailed answer in Chinese in the 'details' field.\n[Example]\n{"details_cn": "my answer..."}'); 147 | bot3.chat(delta); 148 | bot3.on('response', (content) => { 149 | // Stream data push completed. 150 | console.log('bot3 response finished', content); 151 | }); 152 | }); 153 | ling.close(); // It can be directly closed, and when closing, it checks whether the status of all bots has been finished. 154 | // ------- The work flow end -------- 155 | 156 | // setting below headers for Streaming the data 157 | res.writeHead(200, { 158 | 'Content-Type': "text/event-stream", 159 | 'Cache-Control': "no-cache", 160 | 'Connection': "keep-alive" 161 | }); 162 | 163 | console.log(ling.stream); 164 | 165 | pipeline((ling.stream as any), res); 166 | }); 167 | 168 | app.listen(port, () => { 169 | console.log(`Example app listening at http://localhost:${port}`); 170 | }); 171 | ``` 172 | 173 | Client 174 | 175 | ```vue 176 | 226 | 227 | 234 | ``` 235 | 236 | ## Bot 事件 237 | 238 | ### string-response 239 | 240 | 当 AI 输出的 JSON 中,字符串字段输出完成时,触发这个事件,返回一个 josnuri 对象。 241 | 242 | ### inference-done 243 | 244 | 当 AI 本次推理完成时,触发这个事件,返回完整的输出内容,此时流式输出可能还没结束,数据还在继续发送给前端。 245 | 246 | ### response 247 | 248 | 当 AI 本次生成的数据已经全部发送给前端时触发。 249 | 250 | > 注意:通常情况下,string-response 先于 inference-done 先于 response。 251 | 252 | ## Custom Event 253 | 254 | 有时候我们希望发送自定义事件给前端,让前端更新状态,可以在server使用 `ling.sendEvent({event, data})` 推送消息给前端。前端可以从流中接收到 JSON `{event, data}` 进行处理。 255 | 256 | ```js 257 | bot.on('inference-done', () => { 258 | bot.sendEvent({event: 'inference-done', state: 'Outline generated!'}); 259 | }); 260 | ``` 261 | 262 | 也可以直接推送 jsonuri 状态,方便前端直接设置 263 | 264 | ```js 265 | bot.on('inference-done', () => { 266 | bot.sendEvent({uri: 'state/outline', delta: true}); 267 | }); 268 | ``` 269 | 270 | ## Server-sent Events 271 | 272 | 可以通过 `ling.setSSE(true)` 转换成 [Server-sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) 的数据格式,这样前端就可以用 `EventSource` API 来处理数据。 273 | 274 | ```js 275 | const es = new EventSource('http://localhost:3000/?question=Can I laid on the cloud?'); 276 | 277 | es.onmessage = (e) => { 278 | console.log(e.data); 279 | } 280 | 281 | es.onopen = () => { 282 | console.log('Connecting'); 283 | } 284 | 285 | es.onerror = (e) => { 286 | console.log(e); 287 | } 288 | ``` 289 | 290 | ## 基本用法 291 | 292 | ```typescript 293 | import { Ling, ChatConfig, ChatOptions } from '@bearbobo/ling'; 294 | 295 | // 配置 LLM 提供商 296 | const config: ChatConfig = { 297 | model_name: 'gpt-4-turbo', // 或其他支持的模型 298 | api_key: 'your-api-key', 299 | endpoint: 'https://api.openai.com/v1/chat/completions', 300 | sse: true // 启用 Server-Sent Events 301 | }; 302 | 303 | // 可选设置 304 | const options: ChatOptions = { 305 | temperature: 0.7, 306 | max_tokens: 2000 307 | }; 308 | 309 | // 创建 Ling 实例 310 | const ling = new Ling(config, options); 311 | 312 | // 创建聊天机器人 313 | const bot = ling.createBot(); 314 | 315 | // 添加系统提示 316 | bot.addPrompt('你是一个有帮助的助手。'); 317 | 318 | // 处理流式响应 319 | ling.on('message', (message) => { 320 | console.log('收到消息:', message); 321 | }); 322 | 323 | // 处理完成事件 324 | ling.on('finished', () => { 325 | console.log('聊天完成'); 326 | }); 327 | 328 | // 处理机器人的响应 329 | bot.on('string-response', (content) => { 330 | console.log('机器人响应:', content); 331 | }); 332 | 333 | // 开始聊天并发送用户消息 334 | await bot.chat('告诉我关于云计算的信息。'); 335 | 336 | // 完成后关闭连接 337 | await ling.close(); 338 | ``` 339 | 340 | ## API 参考 341 | 342 | ### Ling 类 343 | 344 | 用于管理 LLM 交互和工作流的主类。 345 | 346 | ```typescript 347 | new Ling(config: ChatConfig, options?: ChatOptions) 348 | ``` 349 | 350 | #### 方法 351 | 352 | - `createBot(root?: string | null, config?: Partial, options?: Partial)`: 创建一个新的 ChatBot 实例 353 | - `addBot(bot: Bot)`: 添加一个现有的 Bot 到工作流 354 | - `setCustomParams(params: Record)`: 设置用于模板渲染的自定义参数 355 | - `setSSE(sse: boolean)`: 启用或禁用 Server-Sent Events 356 | - `close()`: 关闭所有连接并等待机器人完成 357 | - `cancel()`: 取消所有正在进行的操作 358 | - `sendEvent(event: any)`: 通过管道发送自定义事件 359 | 360 | #### 属性 361 | 362 | - `tube`: 获取底层的 Tube 实例 363 | - `model`: 获取模型名称 364 | - `stream`: 获取 ReadableStream 365 | - `id`: 获取会话 ID 366 | 367 | #### 事件 368 | 369 | - `message`: 收到消息时触发 370 | - `finished`: 所有操作完成时触发 371 | - `canceled`: 操作被取消时触发 372 | - `inference-done`: 机器人完成推理时触发 373 | 374 | ### ChatBot 类 375 | 376 | 处理与 LLM 的单独聊天交互。 377 | 378 | ```typescript 379 | new ChatBot(tube: Tube, config: ChatConfig, options?: ChatOptions) 380 | ``` 381 | 382 | #### 方法 383 | 384 | - `addPrompt(promptTpl: string, promptData?: Record)`: 添加支持模板的系统提示 385 | - `setPrompt(promptTpl: string, promptData?: Record)`: 设置单个系统提示 386 | - `addHistory(messages: ChatCompletionMessageParam[])`: 添加消息历史 387 | - `setHistory(messages: ChatCompletionMessageParam[])`: 设置消息历史 388 | - `addFilter(filter: ((data: any) => boolean) | string | RegExp | FilterMap)`: 添加消息过滤器 389 | - `clearFilters()`: 清除所有过滤器 390 | - `chat(message: string | ChatCompletionContentPart[])`: 使用给定消息开始聊天 391 | - `finish()`: 标记机器人为已完成 392 | 393 | #### 事件 394 | 395 | - `string-response`: 文本响应时触发 396 | - `object-response`: 对象响应时触发 397 | - `inference-done`: 推理完成时触发 398 | - `response`: 完整响应完成时触发 399 | - `error`: 发生错误时触发 400 | 401 | ### ChatConfig 402 | 403 | ```typescript 404 | interface ChatConfig { 405 | model_name: string; // LLM 模型名称 406 | endpoint: string; // API 端点 407 | api_key: string; // API 密钥 408 | api_version?: string; // API 版本(对某些提供商) 409 | session_id?: string; // 自定义会话 ID 410 | max_tokens?: number; // 生成的最大 token 数 411 | sse?: boolean; // 启用 Server-Sent Events 412 | } 413 | ``` 414 | 415 | ### ChatOptions 416 | 417 | ```typescript 418 | interface ChatOptions { 419 | temperature?: number; // 控制随机性(0-1) 420 | presence_penalty?: number; // 惩罚重复 421 | frequency_penalty?: number; // 惩罚频率 422 | stop?: string[]; // 停止序列 423 | top_p?: number; // 核采样参数 424 | response_format?: any; // 响应格式设置 425 | max_tokens?: number; // 生成的最大 token 数 426 | quiet?: boolean; // 抑制输出 427 | bot_id?: string; // 自定义机器人 ID 428 | } 429 | ``` 430 | 431 | ## 贡献 432 | 433 | 欢迎贡献!请随时提交 Pull Request。 434 | 435 | 1. Fork 仓库 436 | 2. 创建功能分支 (`git checkout -b feature/amazing-feature`) 437 | 3. 提交更改 (`git commit -m 'Add some amazing feature'`) 438 | 4. 推送到分支 (`git push origin feature/amazing-feature`) 439 | 5. 打开 Pull Request 440 | 441 | ## 许可证 442 | 443 | 本项目采用 Apache 许可证 - 详情请参阅 LICENSE 文件。 444 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: "Ling.AI", 6 | description: "A lightweight AI workflow framework optimized for ultra-fast response times.", 7 | themeConfig: { 8 | // https://vitepress.dev/reference/default-theme-config 9 | nav: [ 10 | { text: 'Home', link: '/' }, 11 | { text: 'Docs', link: '/get-started' } 12 | ], 13 | 14 | sidebar: [ 15 | { 16 | text: 'Documentation', 17 | items: [ 18 | { text: 'Get Started', link: '/get-started' }, 19 | { text: 'Runtime API Examples', link: '/api-examples' } 20 | ] 21 | } 22 | ], 23 | 24 | socialLinks: [ 25 | { icon: 'github', link: 'https://github.com/WeHomeBot/ling' } 26 | ] 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | ling.bearbobo.com -------------------------------------------------------------------------------- /docs/api-examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # API Reference 6 | 7 | ## Types 8 | 9 | ### type ChatConfig 10 | 11 | ```ts 12 | interface ChatConfig { 13 | model_name: string; // LLM model name 14 | endpoint: string; // API endpoint 15 | api_key: string; // API key 16 | api_version?: string; // API version (for some providers) 17 | session_id?: string; // Custom session ID 18 | max_tokens?: number; // Maximum tokens to generate 19 | sse?: boolean; // Enable Server-Sent Events 20 | } 21 | ``` 22 | 23 | ### type ChatOptions 24 | 25 | ```ts 26 | interface ChatOptions { 27 | temperature?: number; // Controls randomness (0-1) 28 | presence_penalty?: number; // Penalizes repetition 29 | frequency_penalty?: number; // Penalizes frequency 30 | stop?: string[]; // Stop sequences 31 | top_p?: number; // Nucleus sampling parameter 32 | response_format?: any; // Response format settings 33 | max_tokens?: number; // Maximum tokens to generate 34 | quiet?: boolean; // Suppress output 35 | bot_id?: string; // Custom bot ID 36 | } 37 | ``` 38 | 39 | ## Ling extends EventEmitter 40 | 41 | The main class for managing LLM interactions and workflows. 42 | 43 | ```typescript 44 | new Ling(config: ChatConfig, options?: ChatOptions) 45 | ``` 46 | 47 | ::: details constructor(private config: ChatConfig, private options: ChatOptions = {}) 48 | ```ts 49 | { 50 | super(); 51 | this.tube = new Tube(); 52 | } 53 | ``` 54 | ::: 55 | 56 | ### Methods 57 | 58 | #### createBot 59 | 60 | ::: details createBot(root: string | null = null, config: Partial<ChatConfig> = {}, options: Partial<ChatOptions> = {}) 61 | ```ts 62 | { 63 | config: Partial = {}, 64 | options: Partial = {}) { 65 | const bot = new Bot(this.tube, {...this.config, ...config}, {...this.options, ...options}); 66 | bot.setJSONRoot(root); 67 | bot.setCustomParams(this.customParams); 68 | this.bots.push(bot); 69 | return bot; 70 | } 71 | ``` 72 | ::: 73 | 74 | Creates a new ChatBot instance. The 'root' parameter indicates the default root URI path for the output JSON content. 75 | 76 | #### addBot 77 | 78 | ::: details addBot(bot: Bot) 79 | ```ts 80 | { 81 | this.bots.push(bot); 82 | } 83 | ``` 84 | ::: 85 | 86 | Adds an existing Bot to the workflow. 87 | 88 | #### setCustomParams 89 | 90 | ::: details setCustomParams(params: Record<string, string>) 91 | ```ts 92 | { 93 | this.customParams = {...params}; 94 | } 95 | ``` 96 | ::: 97 | 98 | Sets custom parameters for template rendering. Add default variables to all Bot objects created for Ling, which can be used when rendering prompt templates; the prompt templates are parsed by default using [Nunjucks](https://mozilla.github.io/nunjucks/). 99 | 100 | #### setSSE 101 | 102 | ::: details setSSE(sse: boolean) 103 | ```ts 104 | { 105 | this.tube.setSSE(sse); 106 | } 107 | ``` 108 | ::: 109 | 110 | Enables or disables Server-Sent Events mode. 111 | 112 | ::: tip 113 | Traditionally, a web page has to send a request to the server to receive new data; that is, the page requests data from the server. With server-sent events, it's possible for a server to send new data to a web page at any time, by pushing messages to the web page. These incoming messages can be treated as Events + data inside the web page. 114 | 115 | See more about [SSE](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) 116 | ::: 117 | 118 | #### sendEvent 119 | 120 | ::: details sendEvent(event: any) 121 | ```ts 122 | { 123 | this.tube.enqueue(event); 124 | } 125 | ``` 126 | ::: 127 | 128 | #### async close 129 | 130 | ::: details async close() 131 | ```ts 132 | { 133 | while (!this.isAllBotsFinished()) { 134 | await sleep(100); 135 | } 136 | this.tube.close(); 137 | this.bots = []; 138 | } 139 | ``` 140 | ::: 141 | 142 | Closes all connections and waits for bots to finish. Close the data stream when the workflow ends. 143 | 144 | #### async cancel 145 | 146 | ::: details async cancel() 147 | ```ts 148 | { 149 | while (!this.isAllBotsFinished()) { 150 | await sleep(100); 151 | } 152 | this.tube.cancel(); 153 | this.bots = []; 154 | } 155 | ``` 156 | ::: 157 | 158 | Cancels all ongoing operations. Cancel the stream when an exception occurs. 159 | 160 | ### Properties 161 | 162 | #### prop tube 163 | 164 | ::: details get tube() 165 | ```ts 166 | { 167 | return this._tube; 168 | } 169 | ``` 170 | ::: 171 | 172 | Gets the underlying Tube instance. 173 | 174 | #### prop model 175 | 176 | ::: details get model() 177 | ```ts 178 | { 179 | return this.config.model_name; 180 | } 181 | ``` 182 | ::: 183 | 184 | Gets the model name. 185 | 186 | #### prop stream 187 | 188 | ::: details get stream() 189 | ```ts 190 | { 191 | return this.tube.stream; 192 | } 193 | ``` 194 | ::: 195 | 196 | Gets the ReadableStream object created by Ling. 197 | 198 | #### prop id 199 | 200 | ::: details get id() 201 | ```ts 202 | { 203 | return this.config.session_id || this.tube.id; 204 | } 205 | ``` 206 | ::: 207 | 208 | Gets the session ID. 209 | 210 | #### prop closed 211 | 212 | ::: details get closed() 213 | ```ts 214 | { 215 | return this.tube.closed; 216 | } 217 | ``` 218 | ::: 219 | 220 | Whether the workflow has been closed. 221 | 222 | #### prop canceled 223 | 224 | ::: details get canceled() 225 | ```ts 226 | { 227 | return this.tube.canceled; 228 | } 229 | ``` 230 | ::: 231 | 232 | Whether the workflow has been canceled. 233 | 234 | ### Events 235 | 236 | #### event message 237 | 238 | Emitted when a message is received. The message sent to client with an unique event id. 239 | 240 | ```json 241 | { 242 | "id": "t2ke48g1m3:293", 243 | "data": { "uri": "related_question/2", "delta": "s" } 244 | } 245 | ``` 246 | 247 | #### event finished 248 | 249 | Emitted when all operations are finished. 250 | 251 | #### event canceled 252 | 253 | Emitted when operations are canceled. 254 | 255 | #### event inference-done 256 | 257 | Emitted when a bot completes inference. 258 | ``` 259 | 260 | ## ChatBot extends EventEmitter 261 | 262 | Handles individual chat interactions with LLMs. 263 | 264 | ```typescript 265 | new ChatBot(tube: Tube, config: ChatConfig, options?: ChatOptions) 266 | ``` 267 | 268 | ### Methods 269 | 270 | #### addPrompt 271 | 272 | ::: details addPrompt(promptTpl: string, promptData: Record<string, any> = {}) 273 | ```ts 274 | { 275 | const promptText = nunjucks.renderString(promptTpl, { chatConfig: this.config, chatOptions: this.options, ...this.customParams, ...promptData, }); 276 | this.prompts.push({ role: "system", content: promptText }); 277 | } 278 | ``` 279 | ::: 280 | 281 | Adds a system prompt with template support. Set the prompt for the current Bot, supporting Nunjucks templates. 282 | 283 | #### setPrompt 284 | 285 | ::: details setPrompt(promptTpl: string, promptData: Record<string, string> = {}) 286 | ```ts 287 | { 288 | const promptText = nunjucks.renderString(promptTpl, { chatConfig: this.config, chatOptions: this.options, ...this.customParams, ...promptData, }); 289 | this.prompts = [{ role: "system", content: promptText }]; 290 | } 291 | ``` 292 | ::: 293 | 294 | Sets a single system prompt, replacing any existing prompts. 295 | 296 | #### addHistory 297 | 298 | ::: details addHistory(messages: ChatCompletionMessageParam []) 299 | ```ts 300 | { 301 | this.history.push(...messages); 302 | } 303 | ``` 304 | ::: 305 | 306 | Adds message history records. 307 | 308 | #### setHistory 309 | 310 | ::: details setHistory(messages: ChatCompletionMessageParam []) 311 | ```ts 312 | { 313 | this.history = [...messages]; 314 | } 315 | ``` 316 | ::: 317 | 318 | Sets message history, replacing any existing history. 319 | 320 | #### addFilter 321 | 322 | ::: details addFilter(filter: ((data: any) => boolean) | string | RegExp | FilterMap) 323 | ```ts 324 | { 325 | this.tube.addFilter(filter); 326 | } 327 | ``` 328 | ::: 329 | 330 | Adds a filter for messages. 331 | 332 | #### clearFilters 333 | 334 | ::: details clearFilters() 335 | ```ts 336 | { 337 | this.tube.clearFilters(); 338 | } 339 | ``` 340 | ::: 341 | 342 | Clears all filters. 343 | 344 | #### async chat 345 | 346 | ::: details async chat(message: string | ChatCompletionContentPart[]) 347 | ```ts 348 | { 349 | this.chatState = ChatState.CHATTING; 350 | const messages = [...this.prompts, ...this.history, { role: "user", content: message }]; 351 | return getChatCompletions(this.tube, messages, this.config, this.options, 352 | (content) => { // on complete 353 | this.chatState = ChatState.FINISHED; 354 | this.emit('response', content); 355 | }, (content) => { // on string response 356 | this.emit('string-response', content); 357 | }, ({id, data}) => { 358 | this.emit('message', {id, data}); 359 | }).then((content) => { 360 | this.emit('inference-done', content); 361 | }); 362 | } 363 | ``` 364 | ::: 365 | 366 | Starts a chat with the given message. 367 | 368 | #### finish 369 | 370 | ::: details finish() 371 | ```ts 372 | { 373 | this.chatState = ChatState.FINISHED; 374 | } 375 | ``` 376 | ::: 377 | 378 | Marks the bot as finished. 379 | 380 | ### Events 381 | 382 | #### event string-response 383 | 384 | Emitted for text responses. This event is triggered when a string field in the JSON output by the AI is completed, returning a [jsonuri](https://github.com/aligay/jsonuri) object. 385 | 386 | #### event object-response 387 | 388 | Emitted for object responses. 389 | 390 | #### event inference-done 391 | 392 | Emitted when inference is complete. This event is triggered when the AI has completed its current inference, returning the complete output content. At this point, streaming output may not have ended, and data continues to be sent to the front end. 393 | 394 | #### event response 395 | 396 | Emitted when the full response is complete. This event is triggered when all data generated by the AI during this session has been sent to the front end. 397 | 398 | ::: info 399 | Typically, the `string-response` event occurs before `inference-done`, which in turn occurs before `response`. 400 | ::: 401 | 402 | #### event error 403 | 404 | Emitted on errors. 405 | -------------------------------------------------------------------------------- /docs/get-started.md: -------------------------------------------------------------------------------- 1 | # Get Started 2 | 3 | **Ling** is a workflow framework that supports streaming of structured content generated by large language models (LLMs). It enables quick responses to content streams produced by agents or bots within the workflow, thereby reducing waiting times. 4 | 5 | ![ling workflow](https://github.com/user-attachments/assets/e105c4d5-8a8d-4049-bf2f-f3f30d6d5d5c) 6 | 7 | ## Introduction 8 | 9 | Complex AI workflows, such as those found in [Bearbobo Learning Companion](https://bearbobo.com/), require multiple agents/bots to process structured data collaboratively. However, considering real-time responses, utilizing structured data outputs is not conducive to enhancing timeliness through a streaming interface. 10 | 11 | The commonly used JSON data format, although flexible, has structural integrity, meaning it is difficult to parse correctly until all the content is completely outputted. Of course, other structured data formats like YAML can be adopted, but they are not as powerful and convenient as JSON. 12 | Ling is a streaming framework created to address this issue. Its core is a real-time converter that can parse incoming JSON data streams character by character, outputting content in the form of [jsonuri](https://github.com/aligay/jsonuri). 13 | 14 | For example, consider the following JSON format: 15 | 16 | ```json 17 | { 18 | "outline": [ 19 | { 20 | "topic": "What are clouds made of?" 21 | }, 22 | { 23 | "topic": "Why do clouds look soft?" 24 | } 25 | ] 26 | // ... 27 | } 28 | ``` 29 | 30 | During streaming input, the content may be converted in real-time into the following data outputs (using Server-sent Events): 31 | 32 | ``` 33 | data: {"uri": "outline/0/topic", "delta": "clo"} 34 | data: {"uri": "outline/0/topic", "delta": "uds"} 35 | data: {"uri": "outline/0/topic", "delta": "are"} 36 | data: {"uri": "outline/0/topic", "delta": "mad"} 37 | data: {"uri": "outline/0/topic", "delta": "e"} 38 | data: {"uri": "outline/0/topic", "delta": "of"} 39 | data: {"uri": "outline/0/topic", "delta": "?"} 40 | data: {"uri": "outline/1/topic", "delta": "Why"} 41 | data: {"uri": "outline/1/topic", "delta": "do"} 42 | data: {"uri": "outline/1/topic", "delta": "clo"} 43 | data: {"uri": "outline/1/topic", "delta": "uds"} 44 | data: {"uri": "outline/1/topic", "delta": "loo"} 45 | data: {"uri": "outline/1/topic", "delta": "k"} 46 | data: {"uri": "outline/1/topic", "delta": "sof"} 47 | data: {"uri": "outline/1/topic", "delta": "t"} 48 | data: {"uri": "outline/1/topic", "delta": "?"} 49 | ... 50 | ``` 51 | 52 | This method of real-time data transmission facilitates immediate front-end processing and enables responsive UI updates. 53 | 54 | ## Installation 55 | 56 | ```bash 57 | npm install @bearbobo/ling 58 | # or 59 | pnpm add @bearbobo/ling 60 | # or 61 | yarn add @bearbobo/ling 62 | ``` 63 | 64 | ## Supported Models 65 | 66 | Ling supports various LLM providers and models: 67 | 68 | - OpenAI: GPT-4, GPT-4-Turbo, GPT-4o, GPT-3.5-Turbo 69 | - Moonshot: moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k 70 | - Deepseek 71 | - Qwen: qwen-max-longcontext, qwen-long 72 | - Yi: yi-medium 73 | 74 | ## Usage 75 | 76 | ### Basic Usage 77 | 78 | ```typescript 79 | import { Ling, ChatConfig, ChatOptions } from '@bearbobo/ling'; 80 | 81 | // Configure LLM provider 82 | const config: ChatConfig = { 83 | model_name: 'gpt-4-turbo', // or any other supported model 84 | api_key: 'your-api-key', 85 | endpoint: 'https://api.openai.com/v1/chat/completions', 86 | sse: true // Enable Server-Sent Events 87 | }; 88 | 89 | // Optional settings 90 | const options: ChatOptions = { 91 | temperature: 0.7, 92 | max_tokens: 2000 93 | }; 94 | 95 | // Create Ling instance 96 | const ling = new Ling(config, options); 97 | 98 | // Create a bot for chat 99 | const bot = ling.createBot(); 100 | 101 | // Add system prompt 102 | bot.addPrompt('You are a helpful assistant.'); 103 | 104 | // Handle bot's response 105 | bot.on('string-response', (content) => { 106 | console.log('Bot response:', content); 107 | }); 108 | 109 | // Start chat with user message 110 | await bot.chat('Tell me about cloud computing.'); 111 | 112 | // Close the connection when done 113 | await ling.close(); 114 | ``` 115 | 116 | ### Build chat workflow API with Express.js 117 | 118 | ```js 119 | function workflow(question: string, sse: boolean = false) { 120 | const config: ChatConfig = { 121 | model_name, 122 | api_key: apiKey, 123 | endpoint: endpoint, 124 | }; 125 | 126 | const ling = new Ling(config); 127 | ling.setSSE(sse); // use server-sent events 128 | 129 | // Create Bot 130 | const bot = ling.createBot(); 131 | bot.addPrompt(promptTpl); // Set system prompt 132 | bot.chat(question); 133 | bot.on('string-response', ({uri, delta}) => { 134 | // string response completation in a json field 135 | console.log('bot string-response', uri, delta); 136 | 137 | // setupt anothor bot 138 | const bot2 = ling.createBot(); 139 | bot2.addPrompt(promptTpl2); // Set system prompt 140 | bot2.chat(delta); 141 | bot2.on('response', (content) => { 142 | console.log('bot2 response finished', content); 143 | }); 144 | 145 | ... 146 | }); 147 | 148 | ling.close(); 149 | 150 | return ling; 151 | } 152 | 153 | app.get('/', (req, res) => { 154 | // setting below headers for Streaming the data 155 | res.writeHead(200, { 156 | 'Content-Type': "text/event-stream", 157 | 'Cache-Control': "no-cache", 158 | 'Connection': "keep-alive" 159 | }); 160 | 161 | const question = req.query.question as string; 162 | const ling = workflow(question, true); 163 | try { 164 | pipeline((ling.stream as any), res); 165 | } catch(ex) { 166 | ling.cancel(); 167 | } 168 | }); 169 | ``` 170 | 171 | ### Fetch data in front-end 172 | 173 | ```js 174 | const es = new EventSource('http://localhost:3000/?question=can i laid on the cloud?'); 175 | 176 | es.onmessage = (e) => { 177 | console.log(e.data); 178 | } 179 | es.onopen = () => { 180 | console.log('connected'); 181 | } 182 | es.onerror = (e) => { 183 | console.log(e); 184 | } 185 | ``` 186 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Ling.AI" 7 | text: "A lightweight AI workflow framework " 8 | tagline: optimized for ultra-fast response times. 9 | actions: 10 | - theme: brand 11 | text: Get Started 12 | link: /get-started 13 | - theme: alt 14 | text: API References 15 | link: /api-examples 16 | 17 | features: 18 | - title: JSON Streaming 19 | details: Real-time JSON parsing with auto-correction for malformed JSON. 20 | - title: Multi-bot Workflows 21 | details: Create complex AI workflows with multiple bots working together. 22 | - title: Model Compatibility 23 | details: Works with OpenAI, Moonshot, Deepseek, Qwen, Yi and more. 24 | - title: Easy to Use 25 | details: Simple API with ReadableStream and Server-Sent Events support. 26 | --- 27 | 28 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | 6 | export default [ 7 | {files: ["src/**/*.{js,mjs,cjs,ts}", "test/**/*.{js,mjs,cjs,ts}"]}, 8 | {languageOptions: { globals: globals.browser }}, 9 | pluginJs.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | { 12 | plugins: ["jest"], 13 | env: { 14 | node: true, 15 | 'jest/globals': true, 16 | }, 17 | rules: { 18 | 'no-constant-condition': 'off', 19 | '@typescript-eslint/no-unused-vars': 'warn', 20 | }, 21 | } 22 | ]; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | preset: "ts-jest", 4 | rootDir: __dirname, 5 | verbose: true, 6 | testEnvironment: "node", 7 | collectCoverage: false, 8 | moduleFileExtensions: ["ts", "js", "json", "node"], 9 | collectCoverageFrom: ['src/**/*.ts'], 10 | coverageDirectory: 'coverage', 11 | coverageReporters: ['html', 'json', 'lcov'], 12 | testPathIgnorePatterns: ['/node_modules/', '/lib/'], 13 | transform: { 14 | "^.+.tsx?$": ["ts-jest", {}], 15 | }, 16 | watchPathIgnorePatterns: ['/node_modules/', '/lib/'], 17 | testMatch: ["/**/?(*.)+(spec|test).[jt]s"], 18 | testPathIgnorePatterns: ['/node_modules/', '/lib/'], 19 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bearbobo/ling", 3 | "version": "0.13.0", 4 | "description": "The framework for LLMs", 5 | "main": "lib/cjs/index.js", 6 | "module": "lib/esm/index.js", 7 | "scripts": { 8 | "test": "jest --config jest.config.js", 9 | "coverage": "jest --config jest.config.js --coverage", 10 | "test-server": "jiti test/server.ts", 11 | "build": "tsc -p tsconfig.json && tsc -p tsconfig-cjs.json", 12 | "prepublish": "npm run build", 13 | "docs:dev": "vitepress dev docs", 14 | "docs:build": "vitepress build docs", 15 | "docs:preview": "vitepress preview docs" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "Apache", 20 | "devDependencies": { 21 | "@eslint/js": "^9.9.1", 22 | "@types/body-parser": "^1.19.5", 23 | "@types/cors": "^2.8.17", 24 | "@types/express": "^4.17.21", 25 | "@types/jest": "^29.5.12", 26 | "@types/lodash.merge": "^4.6.9", 27 | "@types/node": "^22.5.4", 28 | "@types/nunjucks": "^3.2.6", 29 | "body-parser": "^1.20.3", 30 | "cors": "^2.8.5", 31 | "dotenv": "^16.4.5", 32 | "eslint": "^9.9.1", 33 | "eslint-plugin-jest": "^28.8.3", 34 | "express": "^4.20.0", 35 | "globals": "^15.9.0", 36 | "jest": "^29.7.0", 37 | "jiti": "^1.21.6", 38 | "ts-jest": "^29.2.5", 39 | "typescript": "^5.5.4", 40 | "typescript-eslint": "^8.4.0", 41 | "vitepress": "^1.3.4" 42 | }, 43 | "dependencies": { 44 | "@azure/openai": "2.0.0-beta.1", 45 | "lodash.merge": "^4.6.2", 46 | "nunjucks": "^3.2.4", 47 | "openai": "^4.58.1" 48 | } 49 | } -------------------------------------------------------------------------------- /src/adapter/coze/index.ts: -------------------------------------------------------------------------------- 1 | import { ChatConfig, ChatOptions } from '../../types'; 2 | import { Tube } from '../../tube'; 3 | import { JSONParser, HTMLParser, HTMLParserEvents } from '../../parser'; 4 | import { sleep } from '../../utils'; 5 | 6 | export async function getChatCompletions( 7 | tube: Tube, 8 | messages: any[], 9 | config: ChatConfig, 10 | options?: ChatOptions & {custom_variables?: Record}, 11 | onComplete?: (content: string, function_calls?: any[]) => void, 12 | onStringResponse?: (content: any) => void, 13 | onObjectResponse?: (content: any) => void 14 | ) { 15 | const cozeBotId = config.model_name.replace(/^coze:/, ''); 16 | const { api_key, endpoint } = config as ChatConfig; 17 | 18 | const isQuiet: boolean = !!options?.quiet; 19 | const bot_id = options?.bot_id; 20 | delete options?.quiet; 21 | delete options?.bot_id; 22 | 23 | const isJSONFormat = options?.response_format?.type === 'json_object'; 24 | const isHTMLFormat = options?.response_format?.type === 'html'; 25 | 26 | // system 27 | let system = ''; 28 | const systemPrompts = messages.filter((message) => message.role === 'system'); 29 | if (systemPrompts.length > 0) { 30 | system = systemPrompts.map((message) => message.content).join('\n\n'); 31 | messages = messages.filter((message) => message.role !== system); 32 | } 33 | 34 | const custom_variables = { systemPrompt: system, ...options?.custom_variables }; 35 | const query = messages.pop(); 36 | 37 | 38 | let chat_history = messages.map((message) => { 39 | if (message.role === 'function') { 40 | return { 41 | role: 'assistant', 42 | type: 'tool_response', 43 | content: message.content, 44 | content_type: 'text', 45 | }; 46 | } else if (message.role === 'assistant' && message.function_call) { 47 | return { 48 | role: 'assistant', 49 | type: 'function_call', 50 | content: JSON.stringify(message.function_call), 51 | content_type: 'text', 52 | }; 53 | } else if (message.role === 'assistant') { 54 | return { 55 | role: 'assistant', 56 | type: 'answer', 57 | content: message.content, 58 | content_type: 'text', 59 | }; 60 | } 61 | return { 62 | role: message.role, 63 | content: message.content, 64 | content_type: 'text', 65 | }; 66 | }); 67 | 68 | let parser: JSONParser | HTMLParser | undefined; 69 | const parentPath = options?.response_format?.root; 70 | 71 | if (isJSONFormat) { 72 | parser = new JSONParser({ 73 | parentPath, 74 | autoFix: true, 75 | }); 76 | parser.on('data', (data) => { 77 | tube.enqueue(data, isQuiet, bot_id); 78 | }); 79 | parser.on('string-resolve', (content) => { 80 | if (onStringResponse) onStringResponse(content); 81 | }); 82 | parser.on('object-resolve', (content) => { 83 | if (onObjectResponse) onObjectResponse(content); 84 | }); 85 | } else if(isHTMLFormat) { 86 | if(parentPath) throw new Error('Don\'t support parent path in HTML Format'); 87 | parser = new HTMLParser(); 88 | parser.on(HTMLParserEvents.OPEN_TAG, (path, name, attributes) => { 89 | tube.enqueue({ path, type:'open_tag', name, attributes }, isQuiet, bot_id); 90 | }); 91 | parser.on(HTMLParserEvents.CLOSE_TAG, (path, name) => { 92 | tube.enqueue({ path, type:'close_tag', name }, isQuiet, bot_id); 93 | if (onObjectResponse) onObjectResponse({path, name}); 94 | }); 95 | parser.on(HTMLParserEvents.TEXT_DELTA, (path, text) => { 96 | tube.enqueue({ path, type:'text_delta', delta: text }, isQuiet, bot_id); 97 | }); 98 | parser.on(HTMLParserEvents.TEXT, (path, text) => { 99 | if(path.endsWith('script') || path.endsWith('style')) { 100 | tube.enqueue({ path, type:'text_delta', delta: text }, isQuiet, bot_id); 101 | } 102 | if (onStringResponse) onStringResponse({path, text}); 103 | }); 104 | } 105 | 106 | const _payload = { 107 | bot_id: cozeBotId, 108 | user: 'bearbobo', 109 | query: query.content, 110 | chat_history, 111 | stream: true, 112 | custom_variables, 113 | } as any; 114 | 115 | const body = JSON.stringify(_payload, null, 2); 116 | 117 | const res = await fetch(endpoint, { 118 | method: 'POST', 119 | headers: { 120 | 'Content-Type': 'application/json', 121 | Authorization: `Bearer ${api_key}`, 122 | }, 123 | body, 124 | }); 125 | 126 | const reader = res.body?.getReader(); 127 | if(!reader) { 128 | console.error('No reader'); 129 | tube.cancel(); 130 | return; 131 | } 132 | 133 | let content = ''; 134 | const enc = new TextDecoder('utf-8'); 135 | let buffer = ''; 136 | let functionCalling = false; 137 | const function_calls = []; 138 | let funcName = ''; 139 | 140 | do { 141 | if (tube.canceled) break; 142 | const { done, value } = await reader.read(); 143 | if(done) break; 144 | const delta = enc.decode(value); 145 | const events = delta.split('\n\n'); 146 | for (const event of events) { 147 | // console.log('event', event); 148 | if (/^\s*data:/.test(event)) { 149 | buffer += event.replace(/^\s*data:\s*/, ''); 150 | let data; 151 | try { 152 | data = JSON.parse(buffer); 153 | } catch (ex) { 154 | console.error(ex, buffer); 155 | continue; 156 | } 157 | buffer = ''; 158 | if (data.error_information) { 159 | // console.error(data.error_information.err_msg); 160 | tube.enqueue({event: 'error', data}, isQuiet, bot_id); 161 | tube.cancel(); 162 | break; 163 | } 164 | const message = data.message; 165 | if (message) { 166 | if (message.type === 'answer') { 167 | let result = message.content; 168 | if(!content) { // 去掉开头的空格,coze有时候会出现这种情况,会影响 markdown 格式 169 | result = result.trimStart(); 170 | if(!result) continue; 171 | } 172 | content += result; 173 | const chars = [...result]; 174 | for (let i = 0; i < chars.length; i++) { 175 | if (parser) { 176 | parser.trace(chars[i]); 177 | } else { 178 | tube.enqueue({ uri: parentPath, delta: chars[i] }, isQuiet, bot_id); 179 | } 180 | await sleep(50); 181 | } 182 | if(functionCalling) { // function_call 偶尔未返回结果,原因未知 183 | tube.enqueue({event: 'tool_response', data: null}, isQuiet, bot_id); 184 | functionCalling = false; 185 | } 186 | } else if (message.type === 'function_call') { 187 | functionCalling = true; 188 | const func = JSON.parse(message.content); 189 | func.arguments = JSON.stringify(func.arguments); 190 | function_calls.push({ 191 | role: 'assistant', 192 | content, 193 | function_call: func, 194 | }); 195 | funcName = func.name; 196 | tube.enqueue({event: 'function_call', data: func}, isQuiet, bot_id); 197 | } else if (message.type === 'tool_response') { 198 | functionCalling = false; 199 | function_calls.push({ 200 | role: 'function', 201 | name: funcName, 202 | content: message.content, 203 | }); 204 | tube.enqueue({event: 'tool_response', data: message.content}, isQuiet, bot_id); 205 | } 206 | } 207 | } else { 208 | try { 209 | const data = JSON.parse(event); 210 | if (data.code) { 211 | tube.enqueue({event: 'error', data}, isQuiet, bot_id); 212 | tube.cancel(); 213 | } 214 | } catch(ex) {} 215 | } 216 | } 217 | } while (1); 218 | if (!isJSONFormat && onStringResponse) onStringResponse({ uri: parentPath, delta: content }); 219 | if (!tube.canceled && onComplete) onComplete(content, function_calls); 220 | return content; 221 | } -------------------------------------------------------------------------------- /src/adapter/openai/index.test.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { Tube } from '../../tube'; 3 | import type { ChatConfig } from '../../types'; 4 | import { getChatCompletions } from './index'; 5 | 6 | describe('OpenAI', () => { 7 | const apiKey = process.env.API_KEY as string; 8 | const model_name = process.env.MODEL_NAME as string; 9 | const endpoint = process.env.ENDPOINT as string; 10 | 11 | test('single completion with JSON', done => { 12 | done(); 13 | // const tube = new Tube(); 14 | // const messages = [ 15 | // { role: 'system', content: `你用JSON格式回答我,以{开头\n[Example]{answer: "我的回答"}` }, 16 | // { role: 'user', content: '我能躺在云上吗?' }, 17 | // ]; 18 | // const config: ChatConfig = { 19 | // model_name, 20 | // api_key: apiKey, 21 | // endpoint: endpoint, 22 | // }; 23 | 24 | // getChatCompletions(tube, messages, config, { 25 | // frequency_penalty: 0, 26 | // presence_penalty: 0, 27 | // response_format: {type: 'json_object', root: 'bearbobo'}, 28 | // onComplete: (content) => { 29 | // console.log(content); 30 | // }, 31 | // }).then(() => { 32 | // tube.close(); 33 | // }); 34 | 35 | // const reader = tube.stream.getReader(); 36 | // reader.read().then(function processText({ done:_done, value }) : any { 37 | // if (_done) { 38 | // done(); 39 | // return; 40 | // } 41 | // console.log(value); 42 | // return reader.read().then(processText); 43 | // }); 44 | }, 60000); 45 | }); -------------------------------------------------------------------------------- /src/adapter/openai/index.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | 3 | import { AzureOpenAI } from "openai"; 4 | import "@azure/openai/types"; 5 | 6 | import { ChatConfig, ChatOptions } from '../../types'; 7 | import { Tube } from '../../tube'; 8 | import { JSONParser, HTMLParser, HTMLParserEvents } from '../../parser'; 9 | import { sleep } from '../../utils'; 10 | 11 | import "dotenv/config"; 12 | 13 | const DEFAULT_CHAT_OPTIONS = { 14 | temperature: 0.9, 15 | top_p: 1, 16 | frequency_penalty: 0, 17 | presence_penalty: 0, 18 | }; 19 | 20 | export async function getChatCompletions( 21 | tube: Tube, 22 | messages: any[], 23 | config: ChatConfig, 24 | options?: ChatOptions, 25 | onComplete?: (content: string) => void, 26 | onStringResponse?: (content: any) => void, 27 | onObjectResponse?: (content: any) => void 28 | ) { 29 | options = {...DEFAULT_CHAT_OPTIONS, ...options}; 30 | if (options.response_format) { // 防止原始引用对象里的值被删除 31 | options.response_format = {type: options.response_format.type, root: options.response_format.root}; 32 | } 33 | options.max_tokens = options.max_tokens || config.max_tokens || 4096; // || 16384; 34 | 35 | const isQuiet: boolean = !!options.quiet; 36 | const bot_id = options.bot_id; 37 | delete options.quiet; 38 | delete options.bot_id; 39 | 40 | const isJSONFormat = options.response_format?.type === 'json_object'; 41 | const isHTMLFormat = options.response_format?.type === 'html'; 42 | 43 | let client: OpenAI | AzureOpenAI; 44 | let model = ''; 45 | if(config.endpoint.endsWith('openai.azure.com')) { 46 | process.env.AZURE_OPENAI_ENDPOINT=config.endpoint; 47 | const scope = "https://cognitiveservices.azure.com/.default"; 48 | const deployment = config.model_name; 49 | const apiVersion = config.api_version || "2024-07-01-preview"; 50 | client = new AzureOpenAI({ 51 | endpoint: config.endpoint, 52 | apiKey: config.api_key, 53 | // azureADTokenProvider, 54 | apiVersion, 55 | deployment }); 56 | } else { 57 | const {model_name, api_key, endpoint} = config as ChatConfig; 58 | model = model_name; 59 | client = new OpenAI({ 60 | apiKey: api_key, 61 | baseURL: endpoint.replace(/\/chat\/completions$/, ''), 62 | dangerouslyAllowBrowser: true, 63 | }); 64 | } 65 | 66 | const parentPath = options.response_format?.root; 67 | delete options.response_format.root; 68 | if(isHTMLFormat) { 69 | options.response_format = {type: 'text'}; 70 | } 71 | 72 | const events = await client.chat.completions.create({ 73 | messages, 74 | ...options, 75 | model, 76 | stream: true, 77 | }); 78 | 79 | let content = ''; 80 | const buffer: any[] = []; 81 | let done = false; 82 | 83 | let parser: JSONParser | HTMLParser | undefined; 84 | 85 | if (isJSONFormat) { 86 | parser = new JSONParser({ 87 | parentPath, 88 | autoFix: true, 89 | }); 90 | parser.on('data', (data) => { 91 | buffer.push(data); 92 | }); 93 | parser.on('string-resolve', (content) => { 94 | if (onStringResponse) onStringResponse(content); 95 | }); 96 | parser.on('object-resolve', (content) => { 97 | if (onObjectResponse) onObjectResponse(content); 98 | }); 99 | } else if(isHTMLFormat) { 100 | if(parentPath) throw new Error('Don\'t support parent path in HTML Format'); 101 | parser = new HTMLParser(); 102 | parser.on(HTMLParserEvents.OPEN_TAG, (path, name, attributes) => { 103 | tube.enqueue({ path, type:'open_tag', name, attributes }, isQuiet, bot_id); 104 | }); 105 | parser.on(HTMLParserEvents.CLOSE_TAG, (path, name) => { 106 | tube.enqueue({ path, type:'close_tag', name }, isQuiet, bot_id); 107 | if (onObjectResponse) onObjectResponse({path, name}); 108 | }); 109 | parser.on(HTMLParserEvents.TEXT_DELTA, (path, text) => { 110 | tube.enqueue({ path, type:'text_delta', delta: text }, isQuiet, bot_id); 111 | }); 112 | parser.on(HTMLParserEvents.TEXT, (path, text) => { 113 | if(path.endsWith('script') || path.endsWith('style')) { 114 | tube.enqueue({ path, type:'text_delta', delta: text }, isQuiet, bot_id); 115 | } 116 | if (onStringResponse) onStringResponse({path, text}); 117 | }); 118 | } 119 | 120 | const promises: any[] = [ 121 | (async () => { 122 | for await (const event of events) { 123 | if (tube.canceled) break; 124 | const choice = event.choices[0]; 125 | if (choice && choice.delta) { 126 | if (choice.delta.content) { 127 | content += choice.delta.content; 128 | if (parser) { // JSON format 129 | parser.trace(choice.delta.content); 130 | } else { 131 | buffer.push({ uri: parentPath, delta: choice.delta.content }); 132 | } 133 | } 134 | // const filterResults = choice.content_filter_results; 135 | // if (!filterResults) { 136 | // continue; 137 | // } 138 | // if (filterResults.error) { 139 | // console.log( 140 | // `\tContent filter ran into an error ${filterResults.error.code}: ${filterResults.error.message}`, 141 | // ); 142 | // } else { 143 | // const { hate, sexual, selfHarm, violence } = filterResults; 144 | // console.log( 145 | // `\tHate category is filtered: ${hate?.filtered}, with ${hate?.severity} severity`, 146 | // ); 147 | // console.log( 148 | // `\tSexual category is filtered: ${sexual?.filtered}, with ${sexual?.severity} severity`, 149 | // ); 150 | // console.log( 151 | // `\tSelf-harm category is filtered: ${selfHarm?.filtered}, with ${selfHarm?.severity} severity`, 152 | // ); 153 | // console.log( 154 | // `\tViolence category is filtered: ${violence?.filtered}, with ${violence?.severity} severity`, 155 | // ); 156 | // } 157 | } 158 | } 159 | done = true; 160 | // if (parser) { 161 | // parser.finish(); 162 | // } 163 | })(), 164 | (async () => { 165 | let i = 0; 166 | while (!(done && i >= buffer.length)) { 167 | if (i < buffer.length) { 168 | tube.enqueue(buffer[i], isQuiet, bot_id); 169 | i++; 170 | } 171 | const delta = buffer.length - i; 172 | if (done || delta <= 0) await sleep(10); 173 | else await sleep(Math.max(10, 1000 / delta)); 174 | } 175 | if (!tube.canceled && onComplete) onComplete(content); 176 | })(), 177 | ]; 178 | await Promise.race(promises); 179 | if (!isJSONFormat && onStringResponse) onStringResponse({ uri: parentPath, delta: content }); 180 | return content; // inference done 181 | } -------------------------------------------------------------------------------- /src/bot/index.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events'; 2 | 3 | import { Tube } from "../tube"; 4 | import nunjucks from 'nunjucks'; 5 | import { getChatCompletions } from "../adapter/openai"; 6 | import { getChatCompletions as getCozeChatCompletions } from "../adapter/coze"; 7 | 8 | import type { ChatConfig, ChatOptions } from "../types"; 9 | import type { ChatCompletionAssistantMessageParam, ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ChatCompletionContentPart } from "openai/resources/index"; 10 | 11 | import { shortId } from '../utils'; 12 | 13 | type ChatCompletionMessageParam = ChatCompletionSystemMessageParam | ChatCompletionAssistantMessageParam | ChatCompletionUserMessageParam; 14 | 15 | interface FilterMap { 16 | [key: string]: boolean; 17 | } 18 | 19 | export enum WorkState { 20 | INIT = 'init', 21 | WORKING = 'chatting', 22 | INFERENCE_DONE = 'inference-done', 23 | FINISHED = 'finished', 24 | ERROR = 'error', 25 | } 26 | 27 | export abstract class Bot extends EventEmitter { 28 | abstract get state(): WorkState; 29 | } 30 | 31 | export class ChatBot extends Bot { 32 | private prompts: ChatCompletionSystemMessageParam[] = []; 33 | private history: ChatCompletionMessageParam[] = []; 34 | private customParams: Record = {}; 35 | private chatState = WorkState.INIT; 36 | private config: ChatConfig; 37 | private options: ChatOptions; 38 | private id: string; 39 | 40 | constructor(private tube: Tube, config: ChatConfig, options: ChatOptions = {}) { 41 | super(); 42 | this.id = shortId(); 43 | this.config = { ...config }; 44 | this.options = { ...options, bot_id: this.id }; 45 | } 46 | 47 | isJSONFormat() { 48 | return this.options.response_format?.type === 'json_object'; 49 | } 50 | 51 | get root() { 52 | return this.options.response_format?.root; 53 | } 54 | 55 | setJSONRoot(root: string | null) { 56 | if(!this.options.response_format) { 57 | this.options.response_format = { type: 'json_object', root }; 58 | } else { 59 | this.options.response_format.root = root; 60 | } 61 | } 62 | 63 | setCustomParams(params: Record) { 64 | this.customParams = {...params}; 65 | } 66 | 67 | addPrompt(promptTpl: string, promptData: Record = {}) { 68 | const promptText = nunjucks.renderString(promptTpl, { chatConfig: this.config, chatOptions: this.options, ...this.customParams, ...promptData, }); 69 | this.prompts.push({ role: "system", content: promptText }); 70 | } 71 | 72 | setPrompt(promptTpl: string, promptData: Record = {}) { 73 | this.prompts = []; 74 | this.addPrompt(promptTpl, promptData); 75 | } 76 | 77 | addHistory(messages: ChatCompletionMessageParam []) { 78 | this.history.push(...messages); 79 | } 80 | 81 | setHistory(messages: ChatCompletionMessageParam []) { 82 | this.history = messages; 83 | } 84 | 85 | addFilter(filter: ((data: any) => boolean) | string | RegExp | FilterMap) { 86 | if(typeof filter === 'string') { 87 | // 如果是 string,则排除掉该字段 88 | this.tube.addFilter(this.id, (data: any) => data.uri === `${this.root}/${filter}`); 89 | } else if(filter instanceof RegExp) { 90 | // 如果是正则表达式,则过滤掉匹配该正则表达式的字段 91 | this.tube.addFilter(this.id, (data: any) => filter.test(data.uri)); 92 | } else if(typeof filter === 'function') { 93 | // 如果是函数,那么应当过滤掉函数返回值为false的数据,保留返回为true的 94 | this.tube.addFilter(this.id, (data: any) => !filter(data)); 95 | } else if(typeof filter === 'object') { 96 | // 如果是对象,那么应当过滤掉对象中值为false的键,或者保留为true的键 97 | const _filter = filter as FilterMap; 98 | const filterFun = ({uri}: any) => { 99 | if (uri == null) return false; 100 | let isTrueFilter = false; 101 | for(const key in _filter) { 102 | if(uri === `${this.root}/${key}`) { 103 | return !_filter[key]; 104 | } 105 | if(_filter[key] && !isTrueFilter) { 106 | isTrueFilter = true; 107 | } 108 | } 109 | return isTrueFilter; 110 | } 111 | this.tube.addFilter(this.id, filterFun); 112 | } 113 | } 114 | 115 | clearFilters() { 116 | this.tube.clearFilters(this.id); 117 | } 118 | 119 | userMessage(message: string): ChatCompletionUserMessageParam { 120 | return { role: "user", content: message }; 121 | } 122 | 123 | botMessage(message: string): ChatCompletionAssistantMessageParam { 124 | return { role: "assistant", content: message }; 125 | } 126 | 127 | async chat(message: string | ChatCompletionContentPart[]) { 128 | try { 129 | this.chatState = WorkState.WORKING; 130 | const isJSONFormat = this.isJSONFormat(); 131 | const prompts = this.prompts.length > 0 ? [...this.prompts] : []; 132 | if(this.prompts.length === 0 && isJSONFormat) { 133 | prompts.push({ 134 | role: 'system', 135 | content: `[Output]\nOutput with json format, starts with '{'\n[Example]\n{"answer": "My answer"}`, 136 | }); 137 | } 138 | const messages = [...prompts, ...this.history, { role: "user", content: message }]; 139 | if(this.config.endpoint.startsWith('https://api.coze.cn')) { 140 | return await getCozeChatCompletions(this.tube, messages, this.config, {...this.options, custom_variables: this.customParams}, 141 | (content) => { // on complete 142 | this.chatState = WorkState.FINISHED; 143 | this.emit('response', content); 144 | }, (content) => { // on string response 145 | this.emit('string-response', content); 146 | }, (content) => { // on object response 147 | this.emit('object-response', content); 148 | }).then((content) => { // on inference done 149 | this.chatState = WorkState.INFERENCE_DONE; 150 | this.emit('inference-done', content); 151 | }); 152 | } 153 | return await getChatCompletions(this.tube, messages, this.config, this.options, 154 | (content) => { // on complete 155 | this.chatState = WorkState.FINISHED; 156 | this.emit('response', content); 157 | }, (content) => { // on string response 158 | this.emit('string-response', content); 159 | }, (content) => { // on object response 160 | this.emit('object-response', content); 161 | }).then((content) => { // on inference done 162 | this.chatState = WorkState.INFERENCE_DONE; 163 | this.emit('inference-done', content); 164 | }); 165 | } catch(ex: any) { 166 | console.error(ex); 167 | this.chatState = WorkState.ERROR; 168 | // 不主动发error给客户端 169 | // this.tube.enqueue({event: 'error', data: ex.message}); 170 | this.emit('error', ex.message); 171 | // this.tube.cancel(); 172 | } 173 | } 174 | 175 | finish() { 176 | this.emit('inference-done', 'null'); 177 | this.emit('response', 'null'); 178 | this.chatState = WorkState.FINISHED; 179 | } 180 | 181 | get state() { 182 | return this.chatState; 183 | } 184 | } -------------------------------------------------------------------------------- /src/flow/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Flow } from './index'; 2 | 3 | describe('Flow', () => { 4 | let flow: Flow; 5 | 6 | beforeEach(() => { 7 | flow = new Flow(); 8 | }); 9 | 10 | test('创建Flow实例', () => { 11 | expect(flow).toBeInstanceOf(Flow); 12 | }); 13 | 14 | test('创建节点', () => { 15 | const node = flow.node(); 16 | expect(node).toBeDefined(); 17 | }); 18 | 19 | test('基本节点执行', async () => { 20 | const mockFn = jest.fn(); 21 | const node = flow.node(); 22 | node.execute(({ next }) => { 23 | mockFn(); 24 | next(); 25 | }).finish(); 26 | 27 | await flow.run(); 28 | expect(mockFn).toHaveBeenCalled(); 29 | }); 30 | 31 | test('链式节点执行', async () => { 32 | const results: number[] = []; 33 | const node = flow.node(); 34 | 35 | node.execute(({ next }) => { 36 | results.push(1); 37 | next(); 38 | }) 39 | .execute(({ next }) => { 40 | results.push(2); 41 | next(); 42 | }) 43 | .execute(({ next }) => { 44 | results.push(3); 45 | next(); 46 | }) 47 | .finish(); 48 | 49 | await flow.run(); 50 | expect(results).toEqual([1, 2, 3]); 51 | }); 52 | 53 | test('事件传递', async () => { 54 | const results: any[] = []; 55 | const node = flow.node(); 56 | 57 | const nextNode = node.execute(({ next, emit }) => { 58 | emit('custom-event', 'data1'); 59 | next('data2'); 60 | }) 61 | nextNode.on('custom-event', ({ event, next }) => { 62 | results.push(event.args[0]); 63 | }) 64 | nextNode.execute(({ event, next }) => { 65 | results.push(event.args[0]); 66 | next(); 67 | }) 68 | .finish(); 69 | 70 | await flow.run(); 71 | expect(results).toEqual(['data1', 'data2']); 72 | }); 73 | 74 | test('返回值传递', async () => { 75 | const results: any[] = []; 76 | const node = flow.node(); 77 | 78 | node.execute(() => { 79 | return 'return-value'; 80 | }) 81 | .execute(({ event }) => { 82 | results.push(event.args[0]); 83 | }) 84 | .finish(); 85 | 86 | await flow.run(); 87 | expect(results).toEqual(['return-value']); 88 | }); 89 | 90 | test('异步节点执行', async () => { 91 | const results: number[] = []; 92 | const node = flow.node(); 93 | 94 | node.execute(async ({ next }) => { 95 | await new Promise(resolve => setTimeout(resolve, 10)); 96 | results.push(1); 97 | next(); 98 | }) 99 | .execute(async ({ next }) => { 100 | await new Promise(resolve => setTimeout(resolve, 10)); 101 | results.push(2); 102 | next(); 103 | }) 104 | .finish(); 105 | 106 | await flow.run(); 107 | expect(results).toEqual([1, 2]); 108 | }); 109 | 110 | test('完成事件', async () => { 111 | const finishMock = jest.fn(); 112 | flow.on('finish', finishMock); 113 | 114 | const node = flow.node(); 115 | node.execute(({ next }) => { 116 | next(); 117 | }) 118 | .finish(); 119 | 120 | await flow.run(); 121 | expect(finishMock).toHaveBeenCalled(); 122 | }); 123 | }); -------------------------------------------------------------------------------- /src/flow/index.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events'; 2 | import { shortId } from '../utils'; 3 | 4 | interface FlowEvent { 5 | type: string; 6 | sender?: FlowNode; 7 | args: unknown[]; 8 | } 9 | 10 | interface FlowTaskArgs { 11 | emit: (event: string, ...args: unknown[]) => void; 12 | next: (...args: unknown[]) => void; 13 | event: FlowEvent; 14 | } 15 | 16 | export class Flow extends EventEmitter { 17 | private startNode: FlowNode | null = null; 18 | 19 | node() { 20 | this.startNode = new FlowNode(this); 21 | return this.startNode; 22 | } 23 | async run() { 24 | if(!this.startNode) { 25 | return; 26 | } 27 | const ret = new Promise((resolve) => { 28 | this.once('finish', () => { 29 | resolve(null); 30 | }); 31 | }); 32 | this.startNode.emitter.emit(this.startNode.nextEventType, { 33 | type: this.startNode.nextEventType, 34 | sender: null, 35 | args: [], 36 | }); 37 | 38 | return ret; 39 | } 40 | } 41 | 42 | /* 43 | const node = flow.node(); 44 | const nextNode = node.execute(({emit, next, event}) => { 45 | emit('event',...eventArgs); 46 | next(1, 2, 3); 47 | }); 48 | nextNode.execute(({emit, next, event}) => { 49 | ... 50 | }); 51 | 52 | await flow.run(); 53 | */ 54 | 55 | export class FlowNode { 56 | private id = shortId(); 57 | public emitter = new EventEmitter(); // 事件发射器 58 | 59 | constructor(private flow: Flow, private previousNode: FlowNode | null = null) {} 60 | 61 | finish() { 62 | this.on(this.nextEventType, () => { 63 | this.flow.emit('finish'); 64 | }, true); 65 | } 66 | 67 | on(event: string, listener: (taskArgs: FlowTaskArgs) => any, once: boolean = false) { 68 | const nextNode = new FlowNode(this.flow, this); // 下一个节点 69 | const listen = once? 'once': 'on'; 70 | let callNext = false; 71 | this.emitter[listen](event, async (event: FlowEvent) => { 72 | const ret = await listener({ 73 | emit: (event: string,...args: unknown[]) => { 74 | if(nextNode) { 75 | nextNode.emitter.emit(event, { 76 | type: event, 77 | sender: this, 78 | args, 79 | }); 80 | } 81 | }, 82 | next: (...args: unknown[]) => { 83 | callNext = true; 84 | if(!nextNode) { 85 | return; 86 | } 87 | return nextNode.emitter.emit(nextNode.nextEventType, { 88 | type: nextNode.nextEventType, 89 | sender: this, 90 | args, 91 | }); 92 | }, 93 | event, 94 | }); 95 | if(!callNext && nextNode) { 96 | return nextNode.emitter.emit(nextNode.nextEventType, { 97 | type: nextNode.nextEventType, 98 | sender: this, 99 | args: ret ? [ret]: [], 100 | }); 101 | } 102 | }); 103 | return nextNode; 104 | } 105 | 106 | get nextEventType() { 107 | return `$$NEXT$$_${this.id}`; 108 | } 109 | 110 | execute(task: (arg0: FlowTaskArgs) => void): FlowNode { 111 | const nextNode = this.on(this.nextEventType, task, true); 112 | return nextNode; 113 | } 114 | } -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { Ling } from "./index"; 3 | import type { ChatConfig } from "./types"; 4 | 5 | describe('Line', () => { 6 | const apiKey = process.env.API_KEY as string; 7 | const model_name = process.env.MODEL_NAME as string; 8 | const endpoint = process.env.ENDPOINT as string; 9 | 10 | test('bearbobo bot', done => { 11 | done(); 12 | // const config: ChatConfig = { 13 | // model_name, 14 | // api_key: apiKey, 15 | // endpoint: endpoint, 16 | // }; 17 | 18 | // const ling = new Ling(config); 19 | 20 | // // 工作流 21 | // const bot = ling.createBot(/*'bearbobo'*/); 22 | // bot.addPrompt('你用JSON格式回答我,以{开头\n[Example]\n{"answer": "我的回答"}'); 23 | // bot.chat('木头为什么能燃烧?'); 24 | // bot.on('response', (content) => { 25 | // // 流数据推送完成 26 | // console.log('bot1 response finished', content); 27 | // }); 28 | 29 | // bot.on('string-response', ({uri, delta}) => { 30 | // // JSON中的字符串内容推理完成 31 | // console.log('bot string-response', uri, delta); 32 | 33 | // const bot2 = ling.createBot(/*'bearbobo'*/); 34 | // bot2.addPrompt('将我给你的内容扩写成更详细的内容,用JSON格式回答我,将解答内容的详细文字放在\'details\'字段里,将2-3条相关的其他知识点放在\'related_question\'字段里。\n[Example]\n{"details": "我的详细回答", "related_question": ["相关知识内容",...]}'); 35 | // bot2.chat(delta); 36 | // bot2.on('response', (content) => { 37 | // // 流数据推送完成 38 | // console.log('bot2 response finished', content); 39 | // }); 40 | // }); 41 | 42 | // const reader = ling.stream.getReader(); 43 | // reader.read().then(function processText({ done:_done, value }) : any { 44 | // if (_done) { 45 | // done(); 46 | // return; 47 | // } 48 | // expect(typeof value).toBe('string'); 49 | // console.log(value); 50 | // return reader.read().then(processText); 51 | // }); 52 | 53 | // ling.close(); // 可以直接关闭,关闭时会检查所有bot的状态是否都完成了 54 | }, 60000); 55 | }); -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events'; 2 | import merge from 'lodash.merge'; 3 | 4 | import { ChatBot, Bot, WorkState } from './bot/index'; 5 | import { Tube } from './tube'; 6 | import type { ChatConfig, ChatOptions } from "./types"; 7 | import { sleep, shortId } from './utils'; 8 | import { Flow, FlowNode } from './flow'; 9 | 10 | export type { ChatConfig, ChatOptions } from "./types"; 11 | export type { Tube } from "./tube"; 12 | 13 | export { Bot, ChatBot, WorkState } from "./bot"; 14 | export { Flow, FlowNode }; 15 | 16 | export class Ling extends EventEmitter { 17 | protected _tube: Tube; 18 | protected customParams: Record = {}; 19 | protected bots: Bot[] = []; 20 | protected session_id = shortId(); 21 | private _promise: Promise | null = null; 22 | private _tasks: Promise[] = []; 23 | constructor(protected config: ChatConfig, protected options: ChatOptions = {}) { 24 | super(); 25 | if(config.session_id) { 26 | this.session_id = config.session_id; 27 | delete config.session_id; 28 | } 29 | this._tube = new Tube(this.session_id); 30 | if(config.sse) { 31 | this._tube.setSSE(true); 32 | } 33 | this._tube.on('message', (message) => { 34 | this.emit('message', message); 35 | }); 36 | this._tube.on('finished', () => { 37 | this.emit('finished'); 38 | }); 39 | this._tube.on('canceled', () => { 40 | this.emit('canceled'); 41 | }); 42 | // this._tube.on('error', (error) => { 43 | // this.emit('error', error); 44 | // }); 45 | } 46 | 47 | handleTask(task: () => Promise) { 48 | return new Promise((resolve, reject) => { 49 | this._tasks.push(task().then(resolve).catch(reject)); 50 | }); 51 | } 52 | 53 | get promise() { 54 | if(!this._promise) { 55 | this._promise = new Promise((resolve, reject) => { 56 | let result: any = {}; 57 | this.on('inference-done', (content, bot) => { 58 | let output = bot.isJSONFormat() ? JSON.parse(content) : content; 59 | if(bot.root != null) { 60 | result[bot.root] = output; 61 | } else { 62 | result = merge(result, output); 63 | } 64 | setTimeout(async () => { 65 | // 没有新的bot且其他bot的状态都都推理结束 66 | if(this.bots.every( 67 | (_bot: Bot) => _bot.state === WorkState.INFERENCE_DONE 68 | || _bot.state === WorkState.FINISHED 69 | || _bot.state === WorkState.ERROR || bot === _bot 70 | )) { 71 | await Promise.all(this._tasks); 72 | resolve(result); 73 | } 74 | }); 75 | }); 76 | // this.once('finished', () => { 77 | // resolve(result); 78 | // }); 79 | this.once('error', (error, bot) => { 80 | reject(error); 81 | }); 82 | }); 83 | } 84 | return this._promise; 85 | } 86 | 87 | createBot(root: string | null = null, config: Partial = {}, options: Partial = {}) { 88 | const bot = new ChatBot(this._tube, {...this.config, ...config}, {...this.options, ...options}); 89 | bot.setJSONRoot(root); 90 | bot.setCustomParams(this.customParams); 91 | bot.addListener('error', (error) => { 92 | this.emit('error', error, bot); 93 | }); 94 | bot.addListener('inference-done', (content) => { 95 | this.emit('inference-done', content, bot); 96 | }); 97 | this.bots.push(bot); 98 | return bot; 99 | } 100 | 101 | addBot(bot: Bot) { 102 | this.bots.push(bot); 103 | } 104 | 105 | setCustomParams(params: Record) { 106 | this.customParams = {...params}; 107 | } 108 | 109 | setSSE(sse: boolean) { 110 | this._tube.setSSE(sse); 111 | } 112 | 113 | protected isAllBotsFinished() { 114 | return this.bots.every(bot => bot.state === 'finished' || bot.state === 'error'); 115 | } 116 | 117 | async close() { 118 | while (!this.isAllBotsFinished()) { 119 | await sleep(100); 120 | } 121 | await sleep(500); // 再等0.5秒,确保没有新的 bot 创建,所有 bot 都真正结束 122 | if(!this.isAllBotsFinished()) { 123 | this.close(); // 如果还有 bot 没有结束,则再关闭一次 124 | return; 125 | } 126 | await Promise.all(this._tasks); // 看还有没有任务没有完成 127 | this._tube.close(); 128 | this.bots = []; 129 | this._tasks = []; 130 | } 131 | 132 | async cancel() { 133 | this._tube.cancel(); 134 | this.bots = []; 135 | this._tasks = []; 136 | } 137 | 138 | sendEvent(event: any) { 139 | this._tube.enqueue(event); 140 | } 141 | 142 | flow() { 143 | return new Flow(); 144 | } 145 | 146 | get tube() { 147 | return this._tube; 148 | } 149 | 150 | get model() { 151 | return this.config.model_name; 152 | } 153 | 154 | get stream() { 155 | return this._tube.stream; 156 | } 157 | 158 | get canceled() { 159 | return this._tube.canceled; 160 | } 161 | 162 | get closed() { 163 | return this._tube.closed; 164 | } 165 | 166 | get id() { 167 | return this.session_id; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/parser/html.parser.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'stream'; 2 | 3 | /** 4 | * A fast HTML parser for NodeJS using Writable streams. 5 | * 6 | * What this is: 7 | * Simple and fast HTML parser purley written for NodeJS. No extra production dependencies. 8 | * A handy way parse ATOM/RSS/RDF feeds and such. No validation is made on the document that is parsed. 9 | * 10 | * Motivation 11 | * There is already quite a few parsers out there. I just wanted a parser that was as tiny and fast as possible to handle easy parsing of 12 | * RSS/ATOM/RDF feeds using streams, no fancy stuff needed. If you want more functionality you should check out other recommended parsers (see below) 13 | * 14 | * Usage 15 | * Just #pipe() a and you are ready to listen for events. 16 | * You can also use the #write() method to write directly to the parser. 17 | * 18 | * The source is written using ES2015, babel is used to translate to the dist. 19 | * 20 | * Other recommended parsers for node that are great: 21 | * https://github.com/isaacs/sax-js 22 | * https://github.com/xmppjs/ltx 23 | * 24 | * Events: 25 | * - text 26 | * - instruction 27 | * - opentag 28 | * - closetag 29 | * - cdata 30 | * 31 | * Comments are ignored, so there is no events for them. 32 | * 33 | */ 34 | 35 | // 定义解析器状态的枚举 36 | enum STATE { 37 | TEXT = 0, 38 | TAG_NAME = 1, 39 | INSTRUCTION = 2, 40 | IGNORE_COMMENT = 4, 41 | CDATA = 8, 42 | DOCTYPE = 16, 43 | INIT = 32, 44 | END = 64 45 | } 46 | 47 | // 定义标签类型的枚举 48 | enum TAG_TYPE { 49 | NONE = 0, 50 | OPENING = 1, 51 | CLOSING = 2, 52 | SELF_CLOSING = 3 53 | } 54 | 55 | // 定义事件类型 56 | export const EVENTS = { 57 | TEXT: 'text', 58 | TEXT_DELTA: 'text_delta', 59 | INSTRUCTION: 'instruction', 60 | OPEN_TAG: 'opentag', 61 | CLOSE_TAG: 'closetag', 62 | CDATA: 'cdata', 63 | DOCTYPE: 'doctype' 64 | } as const; 65 | 66 | // 定义事件类型的类型 67 | export type EventType = keyof typeof EVENTS; 68 | 69 | // 定义属性对象的接口 70 | interface Attributes { 71 | [key: string]: string; 72 | } 73 | 74 | // 定义解析标签字符串的返回类型 75 | interface ParsedTag { 76 | name: string; 77 | attributes: Attributes; 78 | } 79 | 80 | function isSelfClosingTag(name: string): boolean { 81 | // HTML自闭合标签列表 82 | const selfClosingTags = [ 83 | 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 84 | 'link', 'meta', 'param', 'source', 'track', 'wbr', '!doctype' 85 | ]; 86 | 87 | // 将标签名转换为小写并检查是否在自闭合标签列表中 88 | return selfClosingTags.includes(name.toLowerCase()); 89 | } 90 | 91 | export class HTMLParser extends Writable { 92 | private stateStack: STATE[] = [STATE.INIT]; 93 | private buffer: string = ''; 94 | private pos: number = 0; 95 | private tagType: TAG_TYPE = TAG_TYPE.NONE; 96 | private pathStack: string[] = []; // 用于跟踪XML路径的栈 97 | private pathStackCount: Record = {}; // 路径栈的长度 98 | 99 | constructor(options?: {parentPath?: string | null}) { 100 | super(); 101 | this.init(); 102 | } 103 | 104 | get state(): STATE | undefined { 105 | return this.stateStack[this.stateStack.length - 1]; 106 | } 107 | 108 | pushStack(path: string) { 109 | const parent = this._getXPath(); 110 | this.pathStackCount[parent] = 1 + (this.pathStackCount[parent] || 0); 111 | this.pathStack.push(`${path}[${this.pathStackCount[parent]}]`); 112 | } 113 | 114 | popStack() { 115 | this.pathStack.pop(); 116 | } 117 | 118 | trace(input: string) { 119 | this.write(input); 120 | } 121 | 122 | init() { 123 | this.stateStack = [STATE.INIT]; 124 | this.buffer = ''; 125 | this.pos = 0; 126 | this.tagType = TAG_TYPE.NONE; 127 | this.pathStack = ['$$root']; // 初始化路径栈 128 | this.removeAllListeners(); 129 | } 130 | 131 | end(args?: any) { 132 | if(this.state !== STATE.END) { 133 | this.emit('end'); // 触发end事件 134 | this.stateStack.push(STATE.END); 135 | } 136 | return super.end(args); 137 | } 138 | 139 | _write(chunk: any, encoding: BufferEncoding, done: (error?: Error | null) => void): void { 140 | const chunkStr = typeof chunk !== 'string' ? chunk.toString() : chunk; 141 | for (let i = 0; i < chunkStr.length; i++) { 142 | if(this.state === STATE.END) break; 143 | const c = chunkStr[i]; 144 | if(this.state === STATE.INIT && c !== '<') { 145 | continue; 146 | } 147 | const prev = this.buffer[this.pos - 1]; 148 | this.buffer += c; 149 | this.pos++; 150 | 151 | switch (this.state) { 152 | case STATE.INIT: 153 | // 这样能过滤掉AI前面输出的没用文本 154 | if (c === '<') this._onStartNewTag(); 155 | break; 156 | case STATE.TEXT: 157 | if (c === '<') this._onStartNewTag(); 158 | else { 159 | const xpath = this._getXPath(); 160 | // 如果是script或者style标签,则不发送TEXT_DELTA事件,否则可能会导致网页编译报错 161 | if(xpath.endsWith('script') || xpath.endsWith('style')) break; 162 | // 从第一个非空的字符开始 163 | if(c.trim()) { 164 | let begining = false; 165 | if(!this.pathStack[this.pathStack.length - 1].startsWith('$$TEXTNODE')) { 166 | this.pushStack('$$TEXTNODE'); 167 | begining = true; 168 | } 169 | if(begining || prev.trim()) { 170 | this.emit(EVENTS.TEXT_DELTA, this._getXPath(), c); 171 | } else { 172 | this.emit(EVENTS.TEXT_DELTA, this._getXPath(), ` ${c}`); 173 | } 174 | } 175 | } 176 | break; 177 | 178 | case STATE.TAG_NAME: 179 | if (prev === '<' && c === '?') { this._onStartInstruction() }; 180 | if (prev === '<' && c === '/') { this._onCloseTagStart() }; 181 | if (this.buffer[this.pos - 3] === '<' && prev === '!' && c === '[') { this._onCDATAStart() }; 182 | if (this.buffer[this.pos - 3] === '<' && prev === '!' && c === '-') { this._onCommentStart() }; 183 | // 检测DOCTYPE 184 | if (this.buffer[this.pos - 3] === '<' && prev === '!' && (c === 'D' || c === 'd')) { this._onDOCTYPEStart() }; 185 | if (c === '>') { 186 | if (prev === "/") { this.tagType |= TAG_TYPE.CLOSING; } 187 | this._onTagCompleted(); 188 | } 189 | break; 190 | 191 | case STATE.INSTRUCTION: 192 | if (prev === '?' && c === '>') this._onEndInstruction(); 193 | break; 194 | 195 | case STATE.CDATA: 196 | if (prev === ']' && c === ']') this._onCDATAEnd(); 197 | break; 198 | 199 | case STATE.IGNORE_COMMENT: 200 | if (this.buffer[this.pos - 3] === '-' && prev === '-' && c === '>') this._onCommentEnd(); 201 | break; 202 | 203 | case STATE.DOCTYPE: 204 | if (c === '>') this._onDOCTYPEEnd(); 205 | break; 206 | } 207 | } 208 | done(); 209 | } 210 | 211 | private _endRecording(): string { 212 | let rec = this.buffer.slice(1, this.pos - 1).trim(); 213 | this.buffer = this.buffer.slice(-1); // Keep last item in buffer for prev comparison in main loop. 214 | this.pos = 1; 215 | rec = rec.charAt(rec.length - 1) === '/' ? rec.slice(0, -1) : rec; 216 | rec = rec.charAt(rec.length - 1) === '>' ? rec.slice(0, -2) : rec; 217 | return rec; 218 | } 219 | 220 | private _onStartNewTag(): void { 221 | const text = this._endRecording().trim(); 222 | if (text) { 223 | if(!this.pathStack[this.pathStack.length - 1].startsWith('$$TEXTNODE')) { 224 | this.pushStack('$$TEXTNODE'); 225 | } 226 | this.emit(EVENTS.TEXT, this._getXPath(), text); 227 | this.popStack(); 228 | } 229 | if(this.state === STATE.TEXT) { 230 | this.stateStack.pop(); 231 | } 232 | this.stateStack.push(STATE.TAG_NAME); 233 | this.tagType = TAG_TYPE.OPENING; 234 | } 235 | 236 | private _onTagCompleted(): void { 237 | const tag = this._endRecording(); 238 | const { name, attributes } = this._parseTagString(tag); 239 | 240 | if ((this.tagType & TAG_TYPE.OPENING) === TAG_TYPE.OPENING) { 241 | this.pushStack(name) 242 | // 对于开标签,先发出事件,然后将标签名添加到路径栈中 243 | this.emit(EVENTS.OPEN_TAG, this._getXPath(), name, attributes); 244 | } 245 | if(isSelfClosingTag(name)) { 246 | this.tagType |= TAG_TYPE.CLOSING; 247 | } 248 | if ((this.tagType & TAG_TYPE.CLOSING) === TAG_TYPE.CLOSING) { 249 | // 对于闭标签,先发出事件,然后从路径栈中移除最后一个元素 250 | this.emit(EVENTS.CLOSE_TAG, this._getXPath(), name, attributes); 251 | this.popStack(); 252 | this.stateStack.pop(); 253 | } 254 | if (this.state === STATE.INIT && (this.tagType & TAG_TYPE.CLOSING) === TAG_TYPE.CLOSING) { 255 | this.end(); 256 | } else { 257 | this.stateStack.push(STATE.TEXT); 258 | } 259 | // this.stateStack.push(STATE.TEXT); 260 | this.tagType = TAG_TYPE.NONE; 261 | } 262 | 263 | private _onCloseTagStart(): void { 264 | this._endRecording(); 265 | this.tagType = TAG_TYPE.CLOSING; 266 | } 267 | 268 | private _onStartInstruction(): void { 269 | this._endRecording(); 270 | if(this.state === STATE.TEXT) { 271 | this.stateStack.pop(); 272 | } 273 | this.stateStack.push(STATE.INSTRUCTION); 274 | } 275 | 276 | private _onEndInstruction(): void { 277 | this.pos -= 1; // Move position back 1 step since instruction ends with '?>' 278 | const inst = this._endRecording(); 279 | const { name, attributes } = this._parseTagString(inst); 280 | this.emit(EVENTS.INSTRUCTION, name, attributes); 281 | this.stateStack.pop(); 282 | this.stateStack.push(STATE.TEXT); 283 | } 284 | 285 | private _onCDATAStart(): void { 286 | this._endRecording(); 287 | if(this.state === STATE.TEXT) { 288 | this.stateStack.pop(); 289 | } 290 | this.stateStack.push(STATE.CDATA); 291 | } 292 | 293 | private _onCDATAEnd(): void { 294 | let text = this._endRecording(); // Will return CDATA[XXX] we regexp out the actual text in the CDATA. 295 | text = text.slice(text.indexOf('[') + 1, text.lastIndexOf(']')); 296 | this.stateStack.pop(); 297 | this.stateStack.push(STATE.TEXT); 298 | 299 | this.emit(EVENTS.CDATA, this._getXPath(), text); 300 | } 301 | 302 | private _onCommentStart(): void { 303 | if(this.state === STATE.TEXT) { 304 | this.stateStack.pop(); 305 | } 306 | this.stateStack.push(STATE.IGNORE_COMMENT); 307 | } 308 | 309 | private _onCommentEnd(): void { 310 | this._endRecording(); 311 | this.stateStack.pop(); 312 | this.stateStack.push(STATE.TEXT); 313 | } 314 | 315 | private _onDOCTYPEStart(): void { 316 | this._endRecording(); 317 | if(this.state === STATE.TEXT) { 318 | this.stateStack.pop(); 319 | } 320 | this.stateStack.push(STATE.DOCTYPE); 321 | } 322 | 323 | private _onDOCTYPEEnd(): void { 324 | const doctype = this._endRecording(); 325 | if (doctype.toUpperCase().startsWith('OCTYPE')) { 326 | this.emit(EVENTS.DOCTYPE, doctype.slice(6).trim()); 327 | } 328 | this.stateStack.pop(); 329 | this.stateStack.push(STATE.TEXT); 330 | } 331 | 332 | /** 333 | * Helper to parse a tag string 'xml version="2.0" encoding="utf-8"' with regexp. 334 | * @param {string} str the tag string. 335 | * @return {ParsedTag} Object containing name and attributes 336 | */ 337 | private _parseTagString(str: string): ParsedTag { 338 | const [name, ...attrs] = str.split(/\s+(?=[\w:-]+=)/g); 339 | const attributes: Attributes = {}; 340 | attrs.forEach((attribute) => { 341 | const [attrName, attrValue] = attribute.split("="); 342 | if (attrName && attrValue) { 343 | attributes[attrName] = attrValue.trim().replace(/"|'/g, ""); 344 | } 345 | }); 346 | return { name, attributes }; 347 | } 348 | 349 | /** 350 | * 获取当前XML路径 351 | * @return {string} 当前XML路径,格式为/root/child 352 | */ 353 | private _getXPath(): string { 354 | return this.pathStack.join('/').slice(6); // 排除$$root 355 | } 356 | } -------------------------------------------------------------------------------- /src/parser/html.test.ts: -------------------------------------------------------------------------------- 1 | import { HTMLParser, EVENTS } from './html.parser'; 2 | 3 | describe('HTMLParser', () => { 4 | let parser: HTMLParser; 5 | 6 | beforeEach(() => { 7 | parser = new HTMLParser(); 8 | }); 9 | 10 | test('simple HTML tag', done => { 11 | const openTags: any[] = []; 12 | const closeTags: any[] = []; 13 | const textContents: any[] = []; 14 | 15 | parser.init(); 16 | parser.on(EVENTS.OPEN_TAG, (xpath, name, attributes) => { 17 | openTags.push({ xpath, name, attributes }); 18 | }); 19 | 20 | parser.on(EVENTS.CLOSE_TAG, (xpath, name, attributes) => { 21 | closeTags.push({ xpath, name, attributes }); 22 | }); 23 | 24 | parser.on(EVENTS.TEXT, (xpath, text) => { 25 | textContents.push({ xpath, text }); 26 | }); 27 | 28 | parser.on('end', () => { 29 | expect(openTags.length).toBe(1); 30 | expect(openTags[0].name).toBe('div'); 31 | expect(closeTags.length).toBe(1); 32 | expect(closeTags[0].name).toBe('div'); 33 | expect(textContents.length).toBe(1); 34 | expect(textContents[0].text).toBe('Hello World'); 35 | done(); 36 | }); 37 | 38 | parser.write('```html\n
Hello World
\n```'); 39 | parser.end(); 40 | }); 41 | 42 | test('nested HTML tags', done => { 43 | const openTags: any[] = []; 44 | const closeTags: any[] = []; 45 | 46 | parser.init(); 47 | parser.on(EVENTS.OPEN_TAG, (xpath, name, attributes) => { 48 | openTags.push({ xpath, name }); 49 | }); 50 | 51 | parser.on(EVENTS.CLOSE_TAG, (xpath, name, attributes) => { 52 | closeTags.push({ xpath, name }); 53 | }); 54 | 55 | parser.on('end', () => { 56 | expect(openTags.length).toBe(3); 57 | expect(openTags[0].name).toBe('div'); 58 | expect(openTags[1].name).toBe('p'); 59 | expect(openTags[2].name).toBe('span'); 60 | expect(closeTags.length).toBe(3); 61 | expect(closeTags[0].name).toBe('span'); 62 | expect(closeTags[1].name).toBe('p'); 63 | expect(closeTags[2].name).toBe('div'); 64 | done(); 65 | }); 66 | 67 | parser.write('

Nested content

'); 68 | parser.end(); 69 | }); 70 | 71 | test('HTML with attributes', done => { 72 | const openTags: any[] = []; 73 | 74 | parser.init(); 75 | parser.on(EVENTS.OPEN_TAG, (xpath, name, attributes) => { 76 | openTags.push({ xpath, name, attributes }); 77 | }); 78 | 79 | parser.on('end', () => { 80 | expect(openTags.length).toBe(1); 81 | expect(openTags[0].name).toBe('div'); 82 | expect(openTags[0].attributes.id).toBe('test'); 83 | expect(openTags[0].attributes.class).toBe('container'); 84 | expect(openTags[0].attributes['data-value']).toBe('123'); 85 | done(); 86 | }); 87 | 88 | parser.write('
Content
'); 89 | parser.end(); 90 | }); 91 | 92 | test('self-closing tags', done => { 93 | const openTags: any[] = []; 94 | const closeTags: any[] = []; 95 | 96 | parser.init(); 97 | parser.on(EVENTS.OPEN_TAG, (xpath, name, attributes) => { 98 | openTags.push({ xpath, name, attributes }); 99 | }); 100 | 101 | parser.on(EVENTS.CLOSE_TAG, (xpath, name, attributes) => { 102 | closeTags.push({ xpath, name, attributes }); 103 | }); 104 | 105 | parser.on('end', () => { 106 | expect(openTags.length).toBe(3); 107 | expect(closeTags.length).toBe(3); 108 | expect(openTags[0].name).toBe('div'); 109 | expect(openTags[1].name).toBe('img'); 110 | expect(openTags[2].name).toBe('br'); 111 | expect(closeTags[0].name).toBe('img'); 112 | expect(closeTags[1].name).toBe('br'); 113 | expect(closeTags[2].name).toBe('div'); 114 | done(); 115 | }); 116 | 117 | parser.write('

'); 118 | parser.end(); 119 | }); 120 | 121 | test('HTML with text delta events', done => { 122 | const textDeltas: any[] = []; 123 | 124 | parser.init(); 125 | parser.on(EVENTS.TEXT_DELTA, (xpath, char) => { 126 | textDeltas.push({ xpath, char }); 127 | }); 128 | 129 | parser.on('end', () => { 130 | expect(textDeltas.length).toBe(10); // 'Hello World' has 10 non space characters 131 | expect(textDeltas.map(d => d.char).join('')).toBe('HelloWorld'); 132 | expect(textDeltas[0].xpath).toBe('/div[1]/$$TEXTNODE[1]'); 133 | expect(textDeltas[8].xpath).toBe('/div[1]/$$TEXTNODE[3]'); 134 | done(); 135 | }); 136 | 137 | parser.write('
Hello
World
'); 138 | parser.end(); 139 | }); 140 | 141 | test('HTML with CDATA section', done => { 142 | const cdataContents: any[] = []; 143 | 144 | parser.on(EVENTS.CDATA, (xpath, content) => { 145 | cdataContents.push({ xpath, content }); 146 | }); 147 | 148 | parser.on('end', () => { 149 | expect(cdataContents.length).toBe(1); 150 | expect(cdataContents[0].content).toBe('This is CDATA content'); 151 | done(); 152 | }); 153 | 154 | parser.write('
'); 155 | parser.end(); 156 | }); 157 | 158 | test('HTML with DOCTYPE', done => { 159 | const doctypes: any[] = []; 160 | 161 | parser.init(); 162 | parser.on(EVENTS.DOCTYPE, (doctype) => { 163 | doctypes.push(doctype); 164 | }); 165 | 166 | parser.on('end', () => { 167 | expect(doctypes.length).toBe(1); 168 | expect(doctypes[0]).toBe('html'); 169 | done(); 170 | }); 171 | 172 | parser.write('Test'); 173 | parser.end(); 174 | }); 175 | 176 | test('HTML with processing instruction', done => { 177 | const instructions: any[] = []; 178 | 179 | parser.init(); 180 | parser.on(EVENTS.INSTRUCTION, (name, attributes) => { 181 | instructions.push({ name, attributes }); 182 | }); 183 | 184 | parser.on('end', () => { 185 | expect(instructions.length).toBe(1); 186 | expect(instructions[0].name).toBe('xml'); 187 | expect(instructions[0].attributes.version).toBe('1.0'); 188 | expect(instructions[0].attributes.encoding).toBe('UTF-8'); 189 | done(); 190 | }); 191 | 192 | parser.write('Test'); 193 | parser.end(); 194 | }); 195 | 196 | test('HTML with comments', done => { 197 | const openTags: any[] = []; 198 | const textContents: any[] = []; 199 | 200 | parser.init(); 201 | parser.on(EVENTS.OPEN_TAG, (xpath, name, attributes) => { 202 | openTags.push({ xpath, name }); 203 | }); 204 | 205 | parser.on(EVENTS.TEXT, (xpath, text) => { 206 | textContents.push({ xpath, text }); 207 | }); 208 | 209 | parser.on('end', () => { 210 | // Comments should be ignored 211 | expect(openTags.length).toBe(1); 212 | expect(textContents.length).toBe(1); 213 | expect(textContents[0].text).toBe('Test'); 214 | done(); 215 | }); 216 | 217 | parser.write('
Test
'); 218 | parser.end(); 219 | }); 220 | 221 | test('HTML with XPath tracking', done => { 222 | const xpaths: string[] = []; 223 | 224 | parser.init(); 225 | parser.on(EVENTS.OPEN_TAG, (xpath, name, attributes) => { 226 | xpaths.push(xpath); 227 | }); 228 | 229 | parser.on('end', () => { 230 | expect(xpaths.length).toBe(3); 231 | expect(xpaths[0]).toBe('/div[1]'); 232 | expect(xpaths[1]).toBe('/div[1]/p[1]'); 233 | expect(xpaths[2]).toBe('/div[1]/p[1]/span[1]'); 234 | done(); 235 | }); 236 | 237 | parser.write('

Test

'); 238 | parser.end(); 239 | }); 240 | 241 | test('HTML with multiple same tags (XPath indexing)', done => { 242 | const xpaths: string[] = []; 243 | 244 | parser.init(); 245 | parser.on(EVENTS.OPEN_TAG, (xpath, name, attributes) => { 246 | xpaths.push(xpath); 247 | }); 248 | 249 | parser.on(EVENTS.TEXT, (xpath, text) => { 250 | xpaths.push(xpath); 251 | }); 252 | 253 | parser.on('end', () => { 254 | expect(xpaths.length).toBe(8); 255 | expect(xpaths[0]).toBe('/div[1]'); 256 | expect(xpaths[1]).toBe('/div[1]/p[1]'); 257 | expect(xpaths[2]).toBe('/div[1]/p[1]/$$TEXTNODE[1]'); 258 | expect(xpaths[3]).toBe('/div[1]/p[2]'); 259 | expect(xpaths[4]).toBe('/div[1]/p[2]/$$TEXTNODE[1]'); 260 | expect(xpaths[5]).toBe('/div[1]/p[3]'); 261 | expect(xpaths[6]).toBe('/div[1]/p[3]/span[1]'); 262 | expect(xpaths[7]).toBe('/div[1]/p[3]/span[1]/$$TEXTNODE[1]'); 263 | done(); 264 | }); 265 | 266 | parser.write('

First

Second

Third

'); 267 | parser.end(); 268 | }); 269 | 270 | test('HTML parsing with trace method', done => { 271 | const openTags: any[] = []; 272 | 273 | parser.init(); 274 | parser.on(EVENTS.OPEN_TAG, (xpath, name, attributes) => { 275 | openTags.push({ xpath, name }); 276 | }); 277 | 278 | parser.on('end', () => { 279 | expect(openTags.length).toBe(1); 280 | expect(openTags[0].name).toBe('div'); 281 | done(); 282 | }); 283 | 284 | parser.trace('
Test
'); 285 | parser.end(); 286 | }); 287 | }); -------------------------------------------------------------------------------- /src/parser/index.ts: -------------------------------------------------------------------------------- 1 | export { JSONParser } from './json.parser'; 2 | export { HTMLParser, EVENTS as HTMLParserEvents } from './html.parser'; -------------------------------------------------------------------------------- /src/parser/json.parser.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events'; 2 | 3 | const enum LexerStates { 4 | Begin = 'Begin', 5 | Object = 'Object', 6 | Array = 'Array', 7 | Key = 'Key', 8 | Value = 'Value', 9 | String = 'String', 10 | Number = 'Number', 11 | Boolean = 'Boolean', 12 | Null = 'Null', 13 | Finish = 'Finish', 14 | Breaker = 'Breaker', 15 | } 16 | 17 | function isNumeric(str: unknown) { 18 | return !isNaN(str as number) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)... 19 | !isNaN(parseFloat(str as string)) // ...and ensure strings of whitespace fail 20 | } 21 | 22 | function isTrue(str: string) { 23 | return str === 'true'; 24 | } 25 | 26 | //判断空白符 27 | function isWhiteSpace(str: string) { 28 | return /^\s+$/.test(str); 29 | } 30 | 31 | function isQuotationMark(str: string) { 32 | return str === '"' || str === '“' || str === '”' || str === '‘' || str === '’' || str === "'"; 33 | } 34 | 35 | export class JSONParser extends EventEmitter { 36 | private content: string[] = []; 37 | private stateStack: LexerStates[] = [LexerStates.Begin]; 38 | private currentToken = ''; 39 | private keyPath: string[] = []; 40 | private arrayIndexStack: any[] = []; 41 | private objectTokenIndexStack: number[] = []; 42 | private autoFix = false; 43 | private debug = false; 44 | private lastPopStateToken: { state: LexerStates, token: string } | null = null; 45 | 46 | constructor(options: { autoFix?: boolean, parentPath?: string | null, debug?: boolean } = { autoFix: false, parentPath: null, debug: false }) { 47 | super(); 48 | this.autoFix = !!options.autoFix; 49 | this.debug = !!options.debug; 50 | if (options.parentPath) this.keyPath.push(options.parentPath); 51 | } 52 | 53 | get currentState() { 54 | return this.stateStack[this.stateStack.length - 1]; 55 | } 56 | 57 | get lastState() { 58 | return this.stateStack[this.stateStack.length - 2]; 59 | } 60 | 61 | get arrayIndex() { 62 | return this.arrayIndexStack[this.arrayIndexStack.length - 1]; 63 | } 64 | 65 | private log(...args: any[]) { 66 | if (this.debug) { 67 | console.log(...args, this.content.join(''), this.stateStack.join('->')); 68 | } 69 | } 70 | 71 | private pushState(state: LexerStates) { 72 | this.log('pushState', state); 73 | this.stateStack.push(state); 74 | if (state === LexerStates.Array) { 75 | this.arrayIndexStack.push({ index: 0 }); 76 | } 77 | if (state === LexerStates.Object || state === LexerStates.Array) { 78 | this.objectTokenIndexStack.push(this.content.length - 1); 79 | } 80 | } 81 | 82 | private popState() { 83 | this.lastPopStateToken = { state: this.currentState, token: this.currentToken }; 84 | this.currentToken = ''; 85 | const state = this.stateStack.pop(); 86 | this.log('popState', state, this.currentState); 87 | if (state === LexerStates.Value) { 88 | this.keyPath.pop(); 89 | } 90 | if (state === LexerStates.Array) { 91 | this.arrayIndexStack.pop(); 92 | } 93 | if (state === LexerStates.Object || state === LexerStates.Array) { 94 | const idx = this.objectTokenIndexStack.pop(); 95 | if(idx != null && idx >= 0) { 96 | const obj = JSON.parse(this.content.slice(idx).join('')); 97 | this.emit('object-resolve', { 98 | uri: this.keyPath.join('/'), 99 | delta: obj, 100 | }); 101 | } 102 | } 103 | return state; 104 | } 105 | 106 | private reduceState() { 107 | const currentState = this.currentState; 108 | if (currentState === LexerStates.Breaker) { 109 | this.popState(); 110 | if(this.currentState === LexerStates.Value) { 111 | this.popState(); 112 | } 113 | } else if (currentState === LexerStates.String) { 114 | const str = this.currentToken; 115 | this.popState(); 116 | if (this.currentState === LexerStates.Key) { 117 | this.keyPath.push(str); 118 | } else if (this.currentState === LexerStates.Value) { 119 | this.emit('string-resolve', { 120 | uri: this.keyPath.join('/'), 121 | delta: JSON.parse(`["${str}"]`)[0], 122 | }); 123 | // this.popState(); 124 | } 125 | } else if (currentState === LexerStates.Number) { 126 | const num = Number(this.currentToken); 127 | this.popState(); 128 | if (this.currentState === LexerStates.Value) { 129 | // ... 130 | this.emit('data', { 131 | uri: this.keyPath.join('/'), // JSONURI https://github.com/aligay/jsonuri 132 | delta: num, 133 | }); 134 | this.popState(); 135 | } 136 | } else if (currentState === LexerStates.Boolean) { 137 | const str = this.currentToken; 138 | this.popState(); 139 | if (this.currentState === LexerStates.Value) { 140 | this.emit('data', { 141 | uri: this.keyPath.join('/'), 142 | delta: isTrue(str), 143 | }); 144 | this.popState(); 145 | } 146 | } else if (currentState === LexerStates.Null) { 147 | this.popState(); 148 | if (this.currentState === LexerStates.Value) { 149 | this.emit('data', { 150 | uri: this.keyPath.join('/'), 151 | delta: null, 152 | }); 153 | this.popState(); 154 | } 155 | } else if (currentState === LexerStates.Array || currentState === LexerStates.Object) { 156 | this.popState(); 157 | if (this.currentState === LexerStates.Begin) { 158 | this.popState(); 159 | this.pushState(LexerStates.Finish); 160 | const data = (new Function(`return ${this.content.join('')}`))(); 161 | this.emit('finish', data); 162 | } else if (this.currentState === LexerStates.Value) { 163 | // this.popState(); 164 | this.pushState(LexerStates.Breaker); 165 | } 166 | } 167 | else { 168 | this.traceError(this.content.join('')); 169 | } 170 | } 171 | 172 | private traceError(input: string) { 173 | // console.error('Invalid Token', input); 174 | this.content.pop(); 175 | if(this.autoFix) { 176 | if (this.currentState === LexerStates.Begin || this.currentState === LexerStates.Finish) { 177 | return; 178 | } 179 | if (this.currentState === LexerStates.Breaker) { 180 | if(this.lastPopStateToken?.state === LexerStates.String) { 181 | // 修复 token 引号转义 182 | const lastPopStateToken = this.lastPopStateToken.token; 183 | this.stateStack[this.stateStack.length - 1] = LexerStates.String; 184 | this.currentToken = lastPopStateToken || ''; 185 | let traceToken = ''; 186 | for(let i = this.content.length - 1; i >= 0; i--) { 187 | if(this.content[i].trim()) { 188 | this.content.pop(); 189 | traceToken = '\\\"' + traceToken; 190 | break; 191 | } 192 | traceToken = this.content.pop() + traceToken; 193 | } 194 | this.trace(traceToken + input); 195 | return; 196 | } 197 | } 198 | if(this.currentState === LexerStates.String) { 199 | // 回车的转义 200 | if(input === '\n') { 201 | if(this.lastState === LexerStates.Value) { 202 | const currentToken = this.currentToken.trimEnd(); 203 | if(currentToken.endsWith(',') || currentToken.endsWith(']') || currentToken.endsWith('}')) { 204 | // 这种情况下是丢失了最后一个引号 205 | for(let i = this.content.length - 1; i >= 0; i--) { 206 | if(this.content[i].trim()) { 207 | break; 208 | } 209 | this.content.pop(); 210 | } 211 | const token = this.content.pop() as string; 212 | // console.log('retrace -> ', '"' + token + input); 213 | this.trace('"' + token + input); 214 | // 这种情况下多发送(emit)出去了一个特殊字符,前端需要修复,发送一个消息让前端能够修复 215 | this.emit('data', { 216 | uri: this.keyPath.join('/'), 217 | delta: '', 218 | error: { 219 | token, 220 | }, 221 | }); 222 | } else { 223 | // this.currentToken += '\\n'; 224 | // this.content.push('\\n'); 225 | this.trace('\\n'); 226 | } 227 | } 228 | return; 229 | } 230 | } 231 | if(this.currentState === LexerStates.Key) { 232 | if(input !== '"') { 233 | // 处理多余的左引号 eg. {""name": "bearbobo"} 234 | if(this.lastPopStateToken?.token === '') { 235 | this.content.pop(); 236 | this.content.push(input); 237 | this.pushState(LexerStates.String); 238 | } 239 | } 240 | // key 的引号后面还有多余内容,忽略掉 241 | return; 242 | } 243 | 244 | if(this.currentState === LexerStates.Value) { 245 | if (input === ',' || input === '}' || input === ']') { 246 | // value 丢失了 247 | this.pushState(LexerStates.Null); 248 | this.currentToken = ''; 249 | this.content.push('null'); 250 | this.reduceState(); 251 | if(input !== ',') { 252 | this.trace(input); 253 | } else { 254 | this.content.push(input); 255 | } 256 | } else { 257 | // 字符串少了左引号 258 | this.pushState(LexerStates.String); 259 | this.currentToken = ''; 260 | this.content.push('"'); 261 | // 不处理 Value 的引号情况,因为前端修复更简单 262 | // if(!isQuotationMark(input)) { 263 | this.trace(input); 264 | // } 265 | } 266 | return; 267 | } 268 | 269 | if(this.currentState === LexerStates.Object) { 270 | // 直接缺少了 key 271 | if(input === ':') { 272 | this.pushState(LexerStates.Key); 273 | this.pushState(LexerStates.String); 274 | this.currentToken = ''; 275 | this.content.push('"'); 276 | this.trace(input); 277 | return; 278 | } 279 | // 一般是key少了左引号 280 | this.pushState(LexerStates.Key); 281 | this.pushState(LexerStates.String); 282 | this.currentToken = ''; 283 | this.content.push('"'); 284 | if(!isQuotationMark(input)) { 285 | // 单引号和中文引号 286 | this.trace(input); 287 | } 288 | return; 289 | } 290 | 291 | if(this.currentState === LexerStates.Number || this.currentState === LexerStates.Boolean || this.currentState === LexerStates.Null) { 292 | // number, boolean 和 null 失败 293 | const currentToken = this.currentToken; 294 | this.stateStack.pop(); 295 | this.currentToken = ''; 296 | // this.currentToken = ''; 297 | for(let i = 0; i < [...currentToken].length; i++) { 298 | this.content.pop(); 299 | } 300 | // console.log('retrace', '"' + this.currentToken + input); 301 | 302 | this.trace('"' + currentToken + input); 303 | return; 304 | } 305 | } 306 | // console.log('Invalid Token', input, this.currentToken, this.currentState, this.lastState, this.lastPopStateToken); 307 | throw new Error('Invalid Token'); 308 | } 309 | 310 | private traceBegin(input: string) { 311 | // TODO: 目前只简单处理了对象和数组的情况,对于其他类型的合法JSON处理需要补充 312 | if (input === '{') { 313 | this.pushState(LexerStates.Object); 314 | } else if (input === '[') { 315 | this.pushState(LexerStates.Array); 316 | } else { 317 | this.traceError(input); 318 | return; // recover 319 | } 320 | } 321 | 322 | private traceObject(input: string) { 323 | // this.currentToken = ''; 324 | if (isWhiteSpace(input) || input === ',') { 325 | return; 326 | } 327 | if (input === '"') { 328 | this.pushState(LexerStates.Key); 329 | this.pushState(LexerStates.String); 330 | } else if (input === '}') { 331 | this.reduceState(); 332 | } else { 333 | this.traceError(input); 334 | } 335 | } 336 | 337 | private traceArray(input: string) { 338 | if (isWhiteSpace(input)) { 339 | return; 340 | } 341 | if (input === '"') { 342 | this.keyPath.push((this.arrayIndex.index++).toString()); 343 | this.pushState(LexerStates.Value); 344 | this.pushState(LexerStates.String); 345 | } 346 | else if (input === '.' || input === '-' || isNumeric(input)) { 347 | this.keyPath.push((this.arrayIndex.index++).toString()); 348 | this.currentToken += input; 349 | this.pushState(LexerStates.Value); 350 | this.pushState(LexerStates.Number); 351 | } 352 | else if (input === 't' || input === 'f') { 353 | this.keyPath.push((this.arrayIndex.index++).toString()); 354 | this.currentToken += input; 355 | this.pushState(LexerStates.Value); 356 | this.pushState(LexerStates.Boolean); 357 | } 358 | else if (input === 'n') { 359 | this.keyPath.push((this.arrayIndex.index++).toString()); 360 | this.currentToken += input; 361 | this.pushState(LexerStates.Value); 362 | this.pushState(LexerStates.Null); 363 | } 364 | else if (input === '{') { 365 | this.keyPath.push((this.arrayIndex.index++).toString()); 366 | this.pushState(LexerStates.Value); 367 | this.pushState(LexerStates.Object); 368 | } 369 | else if (input === '[') { 370 | this.keyPath.push((this.arrayIndex.index++).toString()); 371 | this.pushState(LexerStates.Value); 372 | this.pushState(LexerStates.Array); 373 | } 374 | else if (input === ']') { 375 | this.reduceState(); 376 | } 377 | } 378 | 379 | private traceString(input: string) { 380 | if (input === '\n') { 381 | this.traceError(input); 382 | return; 383 | } 384 | const currentToken = this.currentToken.replace(/\\\\/g, ''); // 去掉转义的反斜杠 385 | if (input === '"' && currentToken[this.currentToken.length - 1] !== '\\') { 386 | // 字符串结束符 387 | const lastState = this.lastState; 388 | this.reduceState(); 389 | if (lastState === LexerStates.Value) { 390 | this.pushState(LexerStates.Breaker); 391 | } 392 | } 393 | else if(this.autoFix && input === ':' && currentToken[this.currentToken.length - 1] !== '\\' && this.lastState === LexerStates.Key) { 394 | // 默认这种情况下少了右引号,补一个 395 | this.content.pop(); 396 | for(let i = this.content.length - 1; i >= 0; i--) { 397 | if(this.content[i].trim()) { 398 | break; 399 | } 400 | this.content.pop(); 401 | } 402 | this.trace('":'); 403 | } 404 | else if(this.autoFix && isQuotationMark(input) && input !== '"' && this.lastState === LexerStates.Key) { 405 | // 处理 key 中的中文引号和单引号 406 | this.content.pop(); 407 | return; 408 | } else { 409 | if (this.lastState === LexerStates.Value) { 410 | if (input !== '\\' && this.currentToken[this.currentToken.length - 1] !== '\\') { 411 | // 如果不是反斜杠,且不构成转义符,则发送出去 412 | this.emit('data', { 413 | uri: this.keyPath.join('/'), 414 | delta: input, 415 | }); 416 | } else if(this.currentToken[this.currentToken.length - 1] === '\\') { 417 | // 如果不是反斜杠,且可能构成转义,需要判断前面的\\的奇偶性 418 | let count = 0; 419 | for (let i = this.currentToken.length - 1; i >= 0; i--) { 420 | if(this.currentToken[i] === '\\') { 421 | count++; 422 | } else { 423 | break; 424 | } 425 | } 426 | if(count % 2) { 427 | // 奇数个反斜杠,构成转义 428 | this.emit('data', { 429 | uri: this.keyPath.join('/'), 430 | delta: JSON.parse(`["\\${input}"]`)[0], 431 | }); 432 | } 433 | } 434 | } 435 | this.currentToken += input; 436 | } 437 | } 438 | 439 | private traceKey(input: string) { 440 | if (isWhiteSpace(input)) { 441 | this.content.pop(); 442 | return; 443 | } 444 | if (input === ':') { 445 | this.popState(); 446 | this.pushState(LexerStates.Value); 447 | } else { 448 | this.traceError(input); 449 | } 450 | } 451 | 452 | private traceValue(input: string) { 453 | if (isWhiteSpace(input)) { 454 | return; 455 | } 456 | if (input === '"') { 457 | this.pushState(LexerStates.String); 458 | } else if (input === '{') { 459 | this.pushState(LexerStates.Object); 460 | } else if (input === '.' || input === '-' || isNumeric(input)) { 461 | this.currentToken += input; 462 | this.pushState(LexerStates.Number); 463 | } else if (input === 't' || input === 'f') { 464 | this.currentToken += input; 465 | this.pushState(LexerStates.Boolean); 466 | } else if (input === 'n') { 467 | this.currentToken += input; 468 | this.pushState(LexerStates.Null); 469 | } else if (input === '[') { 470 | this.pushState(LexerStates.Array); 471 | } else { 472 | this.traceError(input); 473 | } 474 | } 475 | 476 | private traceNumber(input: string) { 477 | if (isWhiteSpace(input)) { 478 | return; 479 | } 480 | if (isNumeric(this.currentToken + input)) { 481 | this.currentToken += input; 482 | return; 483 | } 484 | if (input === ',') { 485 | this.reduceState(); 486 | } else if (input === '}' || input === ']') { 487 | this.reduceState(); 488 | this.content.pop(); 489 | this.trace(input); 490 | } else { 491 | this.traceError(input); 492 | } 493 | } 494 | 495 | private traceBoolean(input: string) { 496 | if (isWhiteSpace(input)) { 497 | return; 498 | } 499 | 500 | if (input === ',') { 501 | if(this.currentToken === 'true' || this.currentToken === 'false') { 502 | this.reduceState(); 503 | } else { 504 | this.traceError(input); 505 | } 506 | return; 507 | } 508 | 509 | if (input === '}' || input === ']') { 510 | if(this.currentToken === 'true' || this.currentToken === 'false') { 511 | this.reduceState(); 512 | this.content.pop(); 513 | this.trace(input); 514 | } else { 515 | this.traceError(input); 516 | } 517 | return; 518 | } 519 | 520 | if ('true'.startsWith(this.currentToken + input) || 'false'.startsWith(this.currentToken + input)) { 521 | this.currentToken += input; 522 | return; 523 | } 524 | 525 | this.traceError(input); 526 | } 527 | 528 | private traceNull(input: string) { 529 | if (isWhiteSpace(input)) { 530 | return; 531 | } 532 | 533 | if (input === ',') { 534 | if(this.currentToken === 'null') { 535 | this.reduceState(); 536 | } else { 537 | this.traceError(input); 538 | } 539 | return; 540 | } 541 | 542 | if (input === '}' || input === ']') { 543 | this.reduceState(); 544 | this.content.pop(); 545 | this.trace(input); 546 | return; 547 | } 548 | 549 | if ('null'.startsWith(this.currentToken + input)) { 550 | this.currentToken += input; 551 | return; 552 | } 553 | 554 | this.traceError(input); 555 | } 556 | 557 | private traceBreaker(input: string) { 558 | if (isWhiteSpace(input)) { 559 | return; 560 | } 561 | if (input === ',') { 562 | this.reduceState(); 563 | } 564 | else if (input === '}' || input === ']') { 565 | this.reduceState(); 566 | this.content.pop(); 567 | this.trace(input); 568 | } else { 569 | this.traceError(input); 570 | } 571 | } 572 | 573 | public finish() { // 结束解析 574 | if(this.currentState !== LexerStates.Finish) { 575 | throw new Error(`Parser not finished: ${this.currentState} | ${this.content.join('')}`); 576 | } 577 | } 578 | 579 | public trace(input: string) { 580 | const currentState = this.currentState; 581 | this.log('trace', JSON.stringify(input), currentState, JSON.stringify(this.currentToken)); 582 | 583 | const inputArray = [...input]; 584 | if (inputArray.length > 1) { 585 | inputArray.forEach((char) => { 586 | this.trace(char); 587 | }); 588 | return; 589 | } 590 | 591 | this.content.push(input); 592 | if (currentState === LexerStates.Begin) { 593 | this.traceBegin(input); 594 | } 595 | else if (currentState === LexerStates.Object) { 596 | this.traceObject(input); 597 | } 598 | else if (currentState === LexerStates.String) { 599 | this.traceString(input); 600 | } 601 | else if (currentState === LexerStates.Key) { 602 | this.traceKey(input); 603 | } 604 | else if (currentState === LexerStates.Value) { 605 | this.traceValue(input); 606 | } 607 | else if (currentState === LexerStates.Number) { 608 | this.traceNumber(input); 609 | } 610 | else if (currentState === LexerStates.Boolean) { 611 | this.traceBoolean(input); 612 | } 613 | else if (currentState === LexerStates.Null) { 614 | this.traceNull(input); 615 | } 616 | else if (currentState === LexerStates.Array) { 617 | this.traceArray(input); 618 | } 619 | else if (currentState === LexerStates.Breaker) { 620 | this.traceBreaker(input); 621 | } 622 | else if (!isWhiteSpace(input)) { 623 | this.traceError(input); 624 | } 625 | } 626 | } 627 | -------------------------------------------------------------------------------- /src/parser/json.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONParser } from './json.parser'; 2 | 3 | describe('JSONParser', () => { 4 | let parser: JSONParser; 5 | 6 | beforeEach(() => { 7 | parser = new JSONParser({ 8 | parentPath: 'x/y', 9 | debug: false, 10 | }); 11 | }); 12 | 13 | test('sample JSON string {}', done => { 14 | const _data = {}; 15 | 16 | parser.on('finish', (data) => { 17 | expect(data).toEqual(_data); 18 | done(); 19 | }); 20 | 21 | parser.trace(JSON.stringify(_data)); 22 | }) 23 | 24 | test('sample JSON string []', done => { 25 | const _data: any = []; 26 | 27 | parser.on('finish', (data) => { 28 | expect(data).toEqual(_data); 29 | done(); 30 | }); 31 | 32 | parser.trace(JSON.stringify(_data)); 33 | }) 34 | 35 | test('sample JSON string Object', done => { 36 | const _arr: string[] = []; 37 | const _data = { "name": "bearbobo" }; 38 | 39 | parser.on('data', (data) => { 40 | _arr.push(data.delta) 41 | }); 42 | parser.on('finish', (data) => { 43 | expect(data).toEqual(_data); 44 | expect(_arr).toEqual(['b', 'e', 'a', 'r', 'b', 'o', 'b', 'o']); 45 | done(); 46 | }); 47 | 48 | parser.trace(JSON.stringify(_data)); 49 | }) 50 | 51 | test('sample JSON string Number', done => { 52 | const _arr: any[] = []; 53 | const _data = { "age": 10 }; 54 | 55 | parser.on('data', (data) => { 56 | _arr.push(data) 57 | }); 58 | parser.on('finish', (data) => { 59 | expect(data).toEqual(_data); 60 | expect(_arr).toEqual([{ uri: 'x/y/age', delta: 10 }]); 61 | done(); 62 | }); 63 | 64 | parser.trace(JSON.stringify(_data)); 65 | }) 66 | 67 | test('sample JSON string Boolean', done => { 68 | const _arr: any[] = []; 69 | const _data = { a: false, b: true, c: true }; 70 | 71 | parser.on('data', (data) => { 72 | _arr.push(data) 73 | }); 74 | parser.on('finish', (data) => { 75 | expect(data).toEqual(_data); 76 | expect(_arr).toEqual([{ uri: 'x/y/a', delta: false }, { uri: 'x/y/b', delta: true }, { uri: 'x/y/c', delta: true }]); 77 | done(); 78 | }); 79 | 80 | parser.trace(JSON.stringify(_data)); 81 | }) 82 | 83 | test('sample JSON object and array', done => { 84 | const _data = { "a": [1, 2, 3], "b": { "c": 4, "d": 5 } }; 85 | parser.on('object-resolve', ({uri, delta}) => { 86 | console.log('object-resolve', uri, delta); 87 | if(uri === 'a') { 88 | expect(delta).toEqual([1, 2, 3]); 89 | } 90 | if(uri === 'b') { 91 | expect(delta).toEqual({c: 4, d: 5}); 92 | } 93 | }) 94 | parser.on('finish', (data) => { 95 | expect(data).toEqual(_data); 96 | done(); 97 | }); 98 | parser.trace(JSON.stringify(_data)); 99 | }) 100 | 101 | test('complex JSON string', done => { 102 | const _arr: any[] = []; 103 | const _data = { "b": { "a\"": "你好,我是波波熊。" }, "c": 1024, "d": true, "e": [1, 2, " 灵", true, false, null, [32], { 'g': 'h' }], "f": null }; 104 | 105 | parser.on('data', (data) => { 106 | _arr.push(data) 107 | }); 108 | parser.on('finish', (data) => { 109 | expect(data).toEqual(_data); 110 | expect(_arr).toEqual([ 111 | { uri: 'x/y/b/a\\"', delta: '你' }, 112 | { uri: 'x/y/b/a\\"', delta: '好' }, 113 | { uri: 'x/y/b/a\\"', delta: ',' }, 114 | { uri: 'x/y/b/a\\"', delta: '我' }, 115 | { uri: 'x/y/b/a\\"', delta: '是' }, 116 | { uri: 'x/y/b/a\\"', delta: '波' }, 117 | { uri: 'x/y/b/a\\"', delta: '波' }, 118 | { uri: 'x/y/b/a\\"', delta: '熊' }, 119 | { uri: 'x/y/b/a\\"', delta: '。' }, 120 | { uri: 'x/y/c', delta: 1024 }, 121 | { uri: 'x/y/d', delta: true }, 122 | { uri: 'x/y/e/0', delta: 1 }, 123 | { uri: 'x/y/e/1', delta: 2 }, 124 | { uri: 'x/y/e/2', delta: ' ' }, 125 | { uri: 'x/y/e/2', delta: '灵' }, 126 | { uri: 'x/y/e/3', delta: true }, 127 | { uri: 'x/y/e/4', delta: false }, 128 | { uri: 'x/y/e/5', delta: null }, 129 | { uri: 'x/y/e/6/0', delta: 32 }, 130 | { uri: 'x/y/e/7/g', delta: 'h' }, 131 | { uri: 'x/y/f', delta: null } 132 | ]); 133 | done(); 134 | }); 135 | 136 | parser.trace(JSON.stringify(_data)); 137 | }); 138 | 139 | test('invalid JSON string', done => { 140 | try { 141 | parser.trace('bearbobo'); 142 | } catch (error: any) { 143 | expect(error.message).toBe('Invalid Token'); 144 | done(); 145 | } 146 | }); 147 | 148 | test('Minimum valid JSON string', done => { 149 | const _arr: any[] = []; 150 | const _data = { "k": "v" }; 151 | 152 | parser.on('data', (data) => { 153 | expect(data.uri.startsWith('x/y')).toBeTruthy(); 154 | _arr.push(data) 155 | }); 156 | parser.on('finish', (data) => { 157 | expect(data).toEqual(_data); 158 | expect(_arr).toEqual([{ uri: 'x/y/k', delta: 'v' }]); 159 | done(); 160 | }); 161 | 162 | parser.trace(JSON.stringify(_data)); 163 | }); 164 | 165 | test('sample JSON string with space', done => { 166 | parser.on('finish', (data) => { 167 | expect(data).toEqual({ 'name': 'bearbobo', 'age': 10, boy: true, hobbies: ['football', 'swiming'], school: null }); 168 | done(); 169 | }) 170 | parser.trace('{ "name" : "bearbobo" , "age" : 10 , "boy" : true , "hobbies" : [ "football", "swiming" ] , "school" : null } '); 171 | }); 172 | 173 | test('Empty JSON string', done => { 174 | try { 175 | parser.trace(''); 176 | } catch (error: any) { 177 | expect(error.message).toBe('Invalid Token'); 178 | done(); 179 | } 180 | }); 181 | 182 | // Currently, JSONL is not supported 183 | test('JSONL', done => { 184 | try { 185 | parser.trace(`{"name":"bearbobo"} 186 | {"name":"ling"}`); 187 | } catch (error: any) { 188 | expect(error.message).toBe('Invalid Token'); 189 | done(); 190 | } 191 | }); 192 | 193 | test('JSON string with number key', done => { 194 | try { 195 | parser.trace('{1: "bearbobo"}'); 196 | } catch (error: any) { 197 | expect(error.message).toBe('Invalid Token'); 198 | done(); 199 | } 200 | }); 201 | 202 | test('JSON string without quotation marks', done => { 203 | try { 204 | parser.trace('{name:bearbobo}'); 205 | } catch (error: any) { 206 | expect(error.message).toBe('Invalid Token'); 207 | done(); 208 | } 209 | }); 210 | 211 | test('JSON string containing illegal characters', done => { 212 | try { 213 | parser.trace(`{'name':"John"}`); 214 | } catch (error: any) { 215 | expect(error.message).toBe('Invalid Token'); 216 | done(); 217 | } 218 | }); 219 | 220 | test('JSON string with illegal boolean value', done => { 221 | try { 222 | parser.trace('{"name":truae}'); 223 | } catch (error: any) { 224 | expect(error.message).toBe('Invalid Token'); 225 | done(); 226 | } 227 | }); 228 | 229 | test('JSON string with undefined value', done => { 230 | try { 231 | parser.trace('{"name":undefined}'); 232 | } catch (error: any) { 233 | expect(error.message).toBe('Invalid Token'); 234 | done(); 235 | } 236 | }); 237 | 238 | // TODO: JSON string with mismatched parentheses will not emit finish event 239 | // test('JSON string with mismatched parentheses', done => { 240 | // try { 241 | // parser.trace('{"name":"bearbobo"'); 242 | // } catch (error: any) { 243 | // expect(error.message).toBe('Invalid Token'); 244 | // done(); 245 | // } 246 | // }, 1000); 247 | 248 | test('JSON string containing illegal characters', done => { 249 | try { 250 | parser.trace('{"name":"Bearbobo\n"}'); 251 | } catch (error: any) { 252 | expect(error.message).toBe('Invalid Token'); 253 | done(); 254 | } 255 | }); 256 | 257 | // TODO: Duplicate keys will be overwritten in the finish event, but will be retained in the data event 258 | test('JSON string with duplicate key', done => { 259 | const _arr: any[] = []; 260 | 261 | parser.on('data', (data) => { 262 | _arr.push(data) 263 | }); 264 | parser.on('finish', (data) => { 265 | expect(data).toEqual({ a: 2048 }); 266 | expect(_arr).toEqual([{ uri: 'x/y/a', delta: 1024, }, { uri: 'x/y/a', delta: 2048 }]); 267 | done(); 268 | }); 269 | 270 | parser.trace('{"a": 1024, "a": 2048}'); 271 | }); 272 | 273 | test('JSON autoFix extra quotation marks of key 1', done => { 274 | // 多一个右引号 275 | const parser = new JSONParser({ 276 | debug: false, 277 | autoFix: true, 278 | }); 279 | const input = `{ 280 | "name"": "bearbobo", 281 | "age" : 10 282 | }`; 283 | // parser.on('data', (data) => { 284 | // console.log(data); 285 | // }); 286 | parser.on('finish', (data) => { 287 | expect(data).toEqual({name: 'bearbobo', age: 10}); 288 | done(); 289 | }); 290 | parser.trace(input); 291 | }); 292 | 293 | test('JSON autoFix extra quotation marks of key 2', done => { 294 | // 多一个左引号 295 | const parser = new JSONParser({ 296 | debug: false, 297 | autoFix: true, 298 | }); 299 | const input = `{ 300 | ""name": "bearbobo", 301 | "age" : 10 302 | }`; 303 | // parser.on('data', (data) => { 304 | // console.log(data); 305 | // }); 306 | parser.on('finish', (data) => { 307 | expect(data).toEqual({name: 'bearbobo', age: 10}); 308 | done(); 309 | }); 310 | parser.trace(input); 311 | }); 312 | 313 | test('JSON autoFix extra quotation marks of key 3', done => { 314 | // key 中间多了引号,后面的内容忽略,注意这里和 jsonrepaire 逻辑不一样,不做转义替换 315 | const parser = new JSONParser({ 316 | debug: false, 317 | autoFix: true, 318 | }); 319 | const input = `{ 320 | "name"abc": "bearbobo", 321 | "age" : 10 322 | }`; 323 | // parser.on('data', (data) => { 324 | // console.log(data); 325 | // }); 326 | parser.on('finish', (data) => { 327 | expect(data).toEqual({name: 'bearbobo', age: 10}); 328 | done(); 329 | }); 330 | parser.trace(input); 331 | }); 332 | 333 | test('JSON autoFix lost quotation marks of key 1', done => { 334 | // 缺失右引号 335 | const parser = new JSONParser({ 336 | debug: false, 337 | autoFix: true, 338 | }); 339 | const input = `{ 340 | "name" : "bearbobo", 341 | "age : 10 342 | }`; 343 | // parser.on('data', (data) => { 344 | // console.log(data); 345 | // }); 346 | parser.on('finish', (data) => { 347 | expect(data).toEqual({"name": 'bearbobo', age: 10}); 348 | done(); 349 | }); 350 | parser.trace(input); 351 | }); 352 | 353 | test('JSON autoFix lost quotation marks of key 2', done => { 354 | // 缺失左引号 355 | const parser = new JSONParser({ 356 | debug: false, 357 | autoFix: true, 358 | }); 359 | const input = `{ 360 | "name" : "bearbobo", 361 | age" : 10 362 | }`; 363 | // parser.on('data', (data) => { 364 | // console.log(data); 365 | // }); 366 | parser.on('finish', (data) => { 367 | expect(data).toEqual({"name": 'bearbobo', age: 10}); 368 | done(); 369 | }); 370 | parser.trace(input); 371 | }); 372 | 373 | test('JSON autoFix lost quotation marks of key 3', done => { 374 | // 两个引号都缺失 375 | const parser = new JSONParser({ 376 | debug: false, 377 | autoFix: true, 378 | }); 379 | const input = `{ 380 | "name" : "bearbobo", 381 | age : 10 382 | }`; 383 | // parser.on('data', (data) => { 384 | // console.log(data); 385 | // }); 386 | parser.on('finish', (data) => { 387 | expect(data).toEqual({"name": 'bearbobo', age: 10}); 388 | done(); 389 | }); 390 | parser.trace(input); 391 | }); 392 | 393 | test('JSON autoFix lost quotation marks of key 4', done => { 394 | // 整个key丢失 395 | const parser = new JSONParser({ 396 | debug: false, 397 | autoFix: true, 398 | }); 399 | const input = `{ 400 | "name" : "bearbobo", 401 | : 10 402 | }`; 403 | // parser.on('data', (data) => { 404 | // console.log(data); 405 | // }); 406 | parser.on('finish', (data) => { 407 | expect(data).toEqual({"name": 'bearbobo', "": 10}); 408 | done(); 409 | }); 410 | parser.trace(input); 411 | }); 412 | 413 | test('JSON autoFix ignore key line break', done => { 414 | // key中的回车符 415 | const parser = new JSONParser({ 416 | debug: false, 417 | autoFix: true, 418 | }); 419 | const input = `{ 420 | "na 421 | me" : "bearbobo", 422 | "age" : 10 423 | }`; 424 | // parser.on('data', (data) => { 425 | // console.log(data); 426 | // }); 427 | parser.on('finish', (data) => { 428 | expect(data).toEqual({"name": 'bearbobo', "age": 10}); 429 | done(); 430 | }); 431 | parser.trace(input); 432 | }); 433 | 434 | test('JSON autoFix ignore value line break', done => { 435 | // key中的回车符 436 | const parser = new JSONParser({ 437 | debug: false, 438 | autoFix: true, 439 | }); 440 | const input = `{ 441 | "name" : "bearbobo", 442 | "age" : 10, 443 | "content" : "hello 444 | world" 445 | }`; 446 | // parser.on('data', (data) => { 447 | // console.log(data); 448 | // }); 449 | parser.on('finish', (data) => { 450 | expect(data).toEqual({"name": 'bearbobo', "age": 10, "content": "hello\nworld"}); 451 | done(); 452 | }); 453 | parser.trace(input); 454 | }); 455 | 456 | test('JSON autoFix lost value', done => { 457 | const parser = new JSONParser({ 458 | debug: false, 459 | autoFix: true, 460 | }); 461 | const input = `{ 462 | "name" : , 463 | "age" : 464 | }`; 465 | // parser.on('data', (data) => { 466 | // console.log(data); 467 | // }); 468 | parser.on('finish', (data) => { 469 | expect(data).toEqual({"name": null, "age": null}); 470 | done(); 471 | }); 472 | parser.trace(input); 473 | }); 474 | 475 | test('JSON autoFix lost quotation marks of string value 1', done => { 476 | // 缺少左引号 477 | const parser = new JSONParser({ 478 | debug: false, 479 | autoFix: true, 480 | }); 481 | const input = `{ 482 | "name" : bearbobo", 483 | "age" : 10 484 | }`; 485 | // parser.on('data', (data) => { 486 | // console.log(data); 487 | // }); 488 | parser.on('finish', (data) => { 489 | expect(data).toEqual({"name": 'bearbobo', "age": 10}); 490 | done(); 491 | }); 492 | parser.trace(input); 493 | }); 494 | 495 | test('JSON autoFix lost quotation marks of string value 1', done => { 496 | // 缺少右引号 497 | const parser = new JSONParser({ 498 | debug: false, 499 | autoFix: true, 500 | }); 501 | const input = `{ 502 | "name" : "bearbobo, 503 | "age" : 10 504 | }`; 505 | // parser.on('data', (data) => { 506 | // console.log(data); 507 | // }); 508 | parser.on('finish', (data) => { 509 | expect(data).toEqual({"name": 'bearbobo', "age": 10}); 510 | done(); 511 | }); 512 | parser.trace(input); 513 | }); 514 | 515 | test('JSON autoFix extra quotation marks of string value 1', done => { 516 | // 缺少右引号 517 | const parser = new JSONParser({ 518 | debug: false, 519 | autoFix: true, 520 | }); 521 | const input = `{ 522 | "name" : "be"a 523 | rbo"bo, 524 | "age" : 10 525 | }`; 526 | // parser.on('data', (data) => { 527 | // console.log(data); 528 | // }); 529 | parser.on('finish', (data) => { 530 | expect(data).toEqual({"name": 'be\"a\nrbo\"bo', "age": 10}); 531 | done(); 532 | }); 533 | parser.trace(input); 534 | }); 535 | 536 | test('JSON autoFix incorrect quotation marks 1', done => { 537 | // 不正确的引号 538 | const parser = new JSONParser({ 539 | debug: false, 540 | autoFix: true, 541 | }); 542 | const input = `{ 543 | 'name" : "bearbobo", 544 | "age" : 10 545 | }`; 546 | parser.on('finish', (data) => { 547 | expect(data).toEqual({"name": 'bearbobo', "age": 10}); 548 | done(); 549 | }); 550 | parser.trace(input); 551 | }); 552 | 553 | test('JSON autoFix incorrect quotation marks 2', done => { 554 | // 不正确的引号 555 | const parser = new JSONParser({ 556 | debug: false, 557 | autoFix: true, 558 | }); 559 | const input = `{ 560 | "name” : "bearbobo", 561 | "age" : 10 562 | }`; 563 | parser.on('finish', (data) => { 564 | expect(data).toEqual({"name": 'bearbobo', "age": 10}); 565 | done(); 566 | }); 567 | parser.trace(input); 568 | }); 569 | 570 | test('JSON autoFix incorrect quotation marks 3', done => { 571 | // 不正确的引号 - 这里不做处理 572 | const parser = new JSONParser({ 573 | debug: false, 574 | autoFix: true, 575 | }); 576 | const input = `{ 577 | "name” : ‘bearbobo', 578 | "age" : 10 579 | }`; 580 | parser.on('finish', (data) => { 581 | expect(data).toEqual({"name": '‘bearbobo\'', "age": 10}); 582 | done(); 583 | }); 584 | parser.trace(input); 585 | }); 586 | 587 | test('JSON autoFix incorrect quotation marks 4', done => { 588 | // 当作漏掉引号处理 589 | const parser = new JSONParser({ 590 | debug: false, 591 | autoFix: true, 592 | }); 593 | const input = `{ 594 | "name” : nul, 595 | "0text" : true, 596 | "age" : 10a, 597 | "school" : [1, 2, 3] 598 | }`; 599 | // parser.on('data', (data) => { 600 | // console.log(data); 601 | // }); 602 | parser.on('finish', (data) => { 603 | expect(data).toEqual({"name": "nul", "0text": true, "age": "10a", "school": [1, 2, 3]}); 604 | done(); 605 | }); 606 | parser.trace(input); 607 | }); 608 | 609 | test('JSON string with emoji', done => { 610 | const parser = new JSONParser({ 611 | debug: false, 612 | autoFix: true, 613 | }); 614 | const input = `{ 615 | "name” : "🐻" 616 | }`; 617 | parser.on('finish', (data) => { 618 | expect(data).toEqual({"name": "🐻"}); 619 | done(); 620 | }); 621 | parser.trace(input); 622 | }); 623 | 624 | test('JSON string with escape character', done => { 625 | const parser = new JSONParser({ 626 | debug: false, 627 | autoFix: true, 628 | }); 629 | const input = `{ 630 | "name” : "bear\\"bobo\\ntest" 631 | }`; 632 | parser.on('data', (data) => { 633 | console.log('escape character', data); 634 | }); 635 | parser.on('string-resolve', (data) => { 636 | console.log('string-resolve', data); 637 | }); 638 | parser.on('finish', (data) => { 639 | expect(data).toEqual({"name": "bear\"bobo\ntest"}); 640 | done(); 641 | }); 642 | parser.trace(input); 643 | }); 644 | 645 | test('JSON with other text', done => { 646 | // 当作漏掉引号处理 647 | const parser = new JSONParser({ 648 | debug: false, 649 | autoFix: true, 650 | }); 651 | const input = `我们可以输出如下JSON数据: 652 | \`\`\`json 653 | { 654 | "name” : nul, 655 | "0text" : true, 656 | "age" : 10a, 657 | "school" : [1, 2, 3] 658 | } 659 | \`\`\``; 660 | parser.on('data', (data) => { 661 | console.log(data); 662 | }); 663 | parser.on('finish', (data) => { 664 | expect(data).toEqual({"name": "nul", "0text": true, "age": "10a", "school": [1, 2, 3]}); 665 | done(); 666 | }); 667 | parser.trace(input); 668 | }); 669 | }) 670 | -------------------------------------------------------------------------------- /src/tube/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Tube } from './index'; 2 | 3 | describe('Tube', () => { 4 | let tube: Tube; 5 | 6 | beforeEach(() => { 7 | tube = new Tube(); 8 | }); 9 | 10 | test('sample tube', done => { 11 | const _arr: any[] = []; 12 | 13 | tube.enqueue('a'); 14 | tube.enqueue('b'); 15 | tube.enqueue('c'); 16 | tube.enqueue('d'); 17 | tube.enqueue('e'); 18 | tube.enqueue('f'); 19 | tube.enqueue('g'); 20 | tube.enqueue('h'); 21 | tube.enqueue('i'); 22 | tube.enqueue('j'); 23 | tube.enqueue('k'); 24 | tube.enqueue('l'); 25 | tube.enqueue('m'); 26 | tube.enqueue('n'); 27 | tube.enqueue('o'); 28 | tube.enqueue('p'); 29 | tube.enqueue('q'); 30 | tube.enqueue('r'); 31 | tube.enqueue('s'); 32 | tube.enqueue('t'); 33 | tube.enqueue('u'); 34 | tube.enqueue('v'); 35 | tube.enqueue('w'); 36 | tube.enqueue('x'); 37 | tube.enqueue('y'); 38 | tube.enqueue('z'); 39 | 40 | tube.close(); 41 | 42 | const reader = tube.stream.getReader(); 43 | reader.read().then(function processText({ done:_done, value }) : any { 44 | if (_done) { 45 | expect(_arr).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', "{\"event\":\"finished\"}\n"]); 46 | done(); 47 | return; 48 | } 49 | _arr.push(value); 50 | return reader.read().then(processText); 51 | }); 52 | }); 53 | 54 | test('cancel tube', done => { 55 | const _arr: any[] = []; 56 | 57 | tube.enqueue('a'); 58 | tube.enqueue('b'); 59 | tube.enqueue('c'); 60 | tube.enqueue('d'); 61 | tube.enqueue('e'); 62 | tube.enqueue('f'); 63 | tube.enqueue('g'); 64 | tube.enqueue('h'); 65 | tube.enqueue('i'); 66 | tube.enqueue('j'); 67 | tube.enqueue('k'); 68 | tube.enqueue('l'); 69 | tube.enqueue('m'); 70 | tube.enqueue('n'); 71 | tube.enqueue('o'); 72 | tube.enqueue('p'); 73 | tube.enqueue('q'); 74 | tube.enqueue('r'); 75 | tube.enqueue('s'); 76 | tube.enqueue('t'); 77 | tube.enqueue('u'); 78 | tube.enqueue('v'); 79 | tube.enqueue('w'); 80 | tube.enqueue('x'); 81 | tube.enqueue('y'); 82 | tube.enqueue('z'); 83 | 84 | tube.cancel(); 85 | 86 | const reader = tube.stream.getReader(); 87 | reader.read().then(function processText({ done:_done, value }) : any { 88 | if (_done) { 89 | expect(_arr).toEqual([]); 90 | done(); 91 | return; 92 | } 93 | _arr.push(value); 94 | return reader.read().then(processText); 95 | }); 96 | }); 97 | }) -------------------------------------------------------------------------------- /src/tube/index.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events'; 2 | import { shortId } from "../utils"; 3 | 4 | export class Tube extends EventEmitter { 5 | private _stream: ReadableStream; 6 | private controller: ReadableStreamDefaultController | null = null; 7 | private _canceled: boolean = false; 8 | private _closed: boolean = false; 9 | private _sse: boolean = false; 10 | private messageIndex = 0; 11 | private filters: Record boolean)[]> = {}; 12 | 13 | constructor(private session_id: string = shortId()) { 14 | super(); 15 | const self = this; 16 | this._stream = new ReadableStream({ 17 | start(controller) { 18 | self.controller = controller; 19 | } 20 | }); 21 | } 22 | 23 | addFilter(bot_id: string, filter: (data: any) => boolean) { 24 | this.filters[bot_id] = this.filters[bot_id] || []; 25 | this.filters[bot_id].push(filter); 26 | } 27 | 28 | clearFilters(bot_id: string) { 29 | this.filters[bot_id] = []; 30 | } 31 | 32 | setSSE(sse: boolean) { 33 | this._sse = sse; 34 | } 35 | 36 | enqueue(data: unknown, isQuiet: boolean = false, bot_id?: string) { 37 | const isFiltered = bot_id && this.filters[bot_id]?.some(filter => filter(data)); 38 | const id = `${this.session_id}:${this.messageIndex++}`; 39 | if (!this._closed) { 40 | try { 41 | if(typeof data !== 'string') { 42 | if(this._sse && (data as any)?.event) { 43 | const event = `event: ${(data as any).event}\n` 44 | if(!isQuiet && !isFiltered) this.controller?.enqueue(event); 45 | this.emit('message', {id, data: event}); 46 | if((data as any).event === 'error') { 47 | this.emit('error', {id, data}); 48 | } 49 | } 50 | data = JSON.stringify(data) + '\n'; // use jsonl (json lines) 51 | } 52 | if(this._sse) { 53 | data = `data: ${(data as string).replace(/\n$/,'')}\nid: ${id}\n\n`; 54 | } 55 | if(!isQuiet && !isFiltered) this.controller?.enqueue(data); 56 | this.emit('message', {id, data}); 57 | } catch(ex: any) { 58 | this._closed = true; 59 | this.emit('error', {id, data: ex.message}); 60 | console.error('enqueue error:', ex); 61 | } 62 | } 63 | } 64 | 65 | close() { 66 | if(this._closed) return; 67 | this.enqueue({event: 'finished'}); 68 | this.emit('finished'); 69 | this._closed = true; 70 | if(!this._sse) this.controller?.close(); 71 | } 72 | 73 | async cancel() { 74 | if(this._canceled) return; 75 | this._canceled = true; 76 | this._closed = true; 77 | try { 78 | this.enqueue({event: 'canceled'}); 79 | this.emit('canceled'); 80 | await this.stream.cancel(); 81 | } catch(ex) {} 82 | } 83 | 84 | get canceled() { 85 | return this._canceled; 86 | } 87 | 88 | get closed() { 89 | return this._closed; 90 | } 91 | 92 | get stream() { 93 | return this._stream; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export enum ChatModel { 2 | GPT4 = 'gpt-4', 3 | GPT4Turbo = 'gpt-4-turbo', 4 | // GPT4Vision = 'gpt-4-vision', 5 | GPT4o = 'gpt-4-o', 6 | GPT35Turbo = 'gpt-35-turbo', 7 | GPT35Turbo16K = 'gpt-35-turbo-16k', 8 | Moonshot8K = 'moonshot-v1-8k', 9 | Moonshot32K = 'moonshot-v1-32k', 10 | Moonshot128K = 'moonshot-v1-128k', 11 | Deepseek = 'deepseek', 12 | QwenMaxLongcontext = 'qwen-max-longcontext', 13 | QwenLong = 'qwen-long', 14 | YiMedium = 'yi-medium', 15 | } 16 | 17 | export interface ChatConfig { 18 | model_name: string; 19 | endpoint: string; 20 | api_key: string; 21 | api_version?: string; 22 | session_id?: string; 23 | max_tokens?: number; 24 | sse?: boolean; 25 | } 26 | 27 | export interface ChatOptions { 28 | temperature?: number; 29 | presence_penalty?: number; 30 | frequency_penalty?: number; 31 | stop?: string[]; 32 | top_p?: number; 33 | response_format?: any; 34 | max_tokens?: number; 35 | quiet?: boolean; 36 | bot_id?: string; 37 | } -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export function shortId() { 3 | return Math.random().toString(36).slice(2, 12); 4 | } 5 | 6 | export function sleep(ms: number) { 7 | return new Promise((resolve) => setTimeout(resolve, ms)); 8 | } -------------------------------------------------------------------------------- /test/server.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import express from 'express'; 4 | import bodyParser from 'body-parser'; 5 | import cors from 'cors'; 6 | 7 | import { Ling } from "../src/index"; 8 | import type { ChatConfig } from "../src/types"; 9 | 10 | import { pipeline } from 'node:stream/promises'; 11 | 12 | const apiKey = process.env.API_KEY as string; 13 | const model_name = process.env.MODEL_NAME as string; 14 | const endpoint = process.env.ENDPOINT as string; 15 | 16 | const app = express(); 17 | 18 | app.use(cors()); 19 | 20 | // parse application/json 21 | app.use(bodyParser.json()); 22 | 23 | const port = 3000; 24 | 25 | function workflow(question: string, sse: boolean = false) { 26 | const config: ChatConfig = { 27 | model_name, 28 | api_key: apiKey, 29 | endpoint: endpoint, 30 | }; 31 | 32 | const ling = new Ling(config); 33 | ling.setSSE(sse); 34 | 35 | // 工作流 36 | const bot = ling.createBot(/*'bearbobo'*/); 37 | bot.addPrompt('你用JSON格式回答我,以{开头\n[Example]\n{"answer": "我的回答"}'); 38 | bot.chat(question); 39 | bot.on('string-response', ({uri, delta}) => { 40 | // JSON中的字符串内容推理完成,将 anwser 字段里的内容发给第二个 bot 41 | console.log('bot string-response', uri, delta); 42 | 43 | const bot2 = ling.createBot(/*'bearbobo'*/); 44 | bot2.addPrompt('将我给你的内容扩写成更详细的内容,用JSON格式回答我,将解答内容的详细文字放在\'details\'字段里,将2-3条相关的其他知识点放在\'related_question\'字段里。\n[Example]\n{"details": "我的详细回答", "related_question": ["相关知识内容",...]}'); 45 | bot2.chat(delta); 46 | bot2.on('response', (content) => { 47 | // 流数据推送完成 48 | console.log('bot2 response finished', content); 49 | }); 50 | 51 | const bot3 = ling.createBot(); 52 | bot3.addPrompt('将我给你的内容**用英文**扩写成更详细的内容,用JSON格式回答我,将解答内容的详细英文放在\'details_eng\'字段里。\n[Example]\n{"details_eng": "my answer..."}'); 53 | bot3.chat(delta); 54 | bot3.on('response', (content) => { 55 | // 流数据推送完成 56 | console.log('bot3 response finished', content); 57 | }); 58 | }); 59 | 60 | ling.on('message', (message) => { 61 | console.log('ling message', message); 62 | }); 63 | 64 | ling.close(); // 可以直接关闭,关闭时会检查所有bot的状态是否都完成了 65 | 66 | return ling; 67 | } 68 | 69 | app.get('/', async (req, res) => { 70 | // setting below headers for Streaming the data 71 | res.writeHead(200, { 72 | 'Content-Type': "text/event-stream", 73 | 'Cache-Control': "no-cache", 74 | 'Connection': "keep-alive" 75 | }); 76 | 77 | const question = req.query.question as string; 78 | const ling = workflow(question, true); 79 | try { 80 | await pipeline((ling.stream as any), res); 81 | } catch(ex) { 82 | ling.cancel(); 83 | } 84 | }); 85 | 86 | app.get('/ai/chat', async (req, res) => { 87 | // setting below headers for Streaming the data 88 | res.writeHead(200, { 89 | 'Content-Type': "text/event-stream", 90 | 'Cache-Control': "no-cache", 91 | 'Connection': "keep-alive" 92 | }); 93 | 94 | const question = req.query.question as string; 95 | const config: ChatConfig = { 96 | model_name, 97 | api_key: apiKey, 98 | endpoint: endpoint, 99 | }; 100 | 101 | const ling = new Ling(config); 102 | ling.setSSE(true); 103 | 104 | const bot = ling.createBot(); 105 | // bot.addPrompt(prompt); 106 | console.log(question); 107 | bot.chat(question); 108 | ling.close(); 109 | 110 | try { 111 | await pipeline((ling.stream as any), res); 112 | } catch(ex) { 113 | ling.cancel(); 114 | } 115 | }); 116 | 117 | app.post('/api', async (req, res) => { 118 | // res.writeHead(200, { 119 | // 'Content-Type': "text/event-stream", 120 | // 'Cache-Control': "no-cache", 121 | // 'Connection': "keep-alive" 122 | // }); 123 | 124 | const question = req.body.question; 125 | const ling = workflow(question); 126 | try { 127 | await pipeline((ling.stream as any), res); 128 | } catch(ex) { 129 | ling.cancel(); 130 | } 131 | }); 132 | 133 | app.listen(port, () => { 134 | console.log(`Example app listening at http://localhost:${port}`); 135 | }); 136 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import { Flow } from '../src/flow/index'; 2 | 3 | (async () => { 4 | const flow = new Flow(); 5 | const node = flow.node(); 6 | const nextNode = node.execute(({ emit, next, event }) => { 7 | emit('event', ...event.args); 8 | next(1, 2, 3); 9 | }); 10 | nextNode.execute(( ) => { 11 | console.log('1111'); 12 | }); 13 | nextNode.execute(async ({ emit, next, event }) => { 14 | console.log(event.args); 15 | return [4, 5, 6]; 16 | }).execute(({ event }) => { 17 | console.log(event.args); 18 | }).finish(); 19 | 20 | console.log('start'); 21 | await flow.run(); 22 | console.log('done'); 23 | // nul 24 | })(); 25 | -------------------------------------------------------------------------------- /tsconfig-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "outDir": "./lib/cjs" 7 | }, 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "Node16", 29 | // "module": "commonjs", /* Specify what module code is generated. */ 30 | // "rootDir": "./", /* Specify the root folder within your source files. */ 31 | "moduleResolution": "Node16", /* Specify how TypeScript looks up a file from a given module specifier. */ 32 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 33 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 34 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 35 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 36 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 37 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 38 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 39 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 40 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 41 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 42 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 43 | // "resolveJsonModule": true, /* Enable importing .json files. */ 44 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 45 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 46 | 47 | /* JavaScript Support */ 48 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 49 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 50 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 51 | 52 | /* Emit */ 53 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 54 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 55 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 56 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 57 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 58 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 59 | "outDir": "./lib/esm", /* Specify an output folder for all emitted files. */ 60 | // "removeComments": true, /* Disable emitting comments. */ 61 | // "noEmit": true, /* Disable emitting files from a compilation. */ 62 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | 75 | /* Interop Constraints */ 76 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 77 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 78 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": true, /* Enable all strict type-checking options. */ 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | }, 109 | "include": [ 110 | "src" 111 | , "test/test.ts" ] 112 | } 113 | --------------------------------------------------------------------------------