Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

💄 style: Optimize smooth output #5824

Merged
merged 18 commits into from
Feb 28, 2025
Merged

💄 style: Optimize smooth output #5824

merged 18 commits into from
Feb 28, 2025

Conversation

sxjeru
Copy link
Contributor

@sxjeru sxjeru commented Feb 7, 2025

💻 变更类型 | Change Type

  • ✨ feat
  • 🐛 fix
  • ♻️ refactor
  • 💄 style
  • 👷 build
  • ⚡️ perf
  • 📝 docs
  • 🔨 chore

🔀 变更说明 | Description of Change

  • 后台持续平滑输出(或者说跳过后台未输出的部分);
  • smoothing.speed 定义为每秒输出 x 个字符;
    现在 speed 为起始速度。
  • 取消后段加速输出,这个可以讨论下,个人感觉如果输出速度匹配阅读速度时,突然加速完全是破坏体验的。
  • 中止输出时立刻给出所有缓存的内容,并停止输出;(好像本来就是?)
  • 立刻输出剩余内容时,不会自动跳转到底部;
  • 后台输出内容时,禁用自动下滑;
    最好是做成开关,是否需要自动下滑。

第二个请求是因为中日韩等字符的信息量显著高于英文字母,加速输出可以减少代码等输出的等待时间。
(可能会影响英文用户正常聊天?)

📝 补充信息 | Additional Information

@dosubot dosubot bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Feb 7, 2025
Copy link

vercel bot commented Feb 7, 2025

@sxjeru is attempting to deploy a commit to the LobeHub Team on Vercel.

A member of the Team first needs to authorize it.

@lobehubbot
Copy link
Member

👍 @sxjeru

Thank you for raising your pull request and contributing to our Community
Please make sure you have followed our contributing guidelines. We will review it as soon as possible.
If you encounter any problems, please feel free to connect with us.
非常感谢您提出拉取请求并为我们的社区做出贡献,请确保您已经遵循了我们的贡献指南,我们会尽快审查它。
如果您遇到任何问题,请随时与我们联系。

Copy link
Contributor

gru-agent bot commented Feb 7, 2025

TestGru Assignment

Summary

Link CommitId Status Reason
Detail db7a469 ✅ Finished

Files

File Pull Request
src/utils/fetch/fetchSSE.ts 🔴 Closed #5826

Tip

You can @gru-agent and leave your feedback. TestGru will make adjustments based on your input

Copy link

codecov bot commented Feb 7, 2025

Codecov Report

Attention: Patch coverage is 94.00000% with 3 lines in your changes missing coverage. Please review.

Project coverage is 91.62%. Comparing base (d1039d6) to head (d6d7442).
Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
src/utils/fetch/fetchSSE.ts 93.87% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5824      +/-   ##
==========================================
- Coverage   91.63%   91.62%   -0.01%     
==========================================
  Files         699      699              
  Lines       65828    65850      +22     
  Branches     4458     4656     +198     
==========================================
+ Hits        60319    60334      +15     
- Misses       5509     5516       +7     
Flag Coverage Δ
app 91.62% <94.00%> (-0.01%) ⬇️
server 97.57% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. labels Feb 7, 2025
@fzlzjerry
Copy link
Contributor

要是影响到英文用户聊天就不太好了吧🤔

@lobehubbot
Copy link
Member

Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿


It would be bad if it affects English user chats 🤔

Copy link
Contributor

@fzlzjerry fzlzjerry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看起来没有问题

@arvinxx
Copy link
Contributor

arvinxx commented Feb 15, 2025

@sxjeru 和 sonnet 3.5 聊了下,看下有没有必要做这里提到的后台切 setInterval


1. 页面可见性问题

在浏览器中,当页面切换到后台时,requestAnimationFrame 的行为会受到影响:

  1. 大多数浏览器会降低或暂停后台标签页的 requestAnimationFrame 调用频率
  2. 某些浏览器甚至会完全停止 requestAnimationFrame 的执行

代码中通过以下几个关键机制来处理这个问题:

2. 基于时间的动画系统

const updateText = (timestamp: number) => {
  if (!lastFrameTime) {
    lastFrameTime = timestamp;
  }
  
  // 1. 计算实际经过的时间
  const frameDuration = timestamp - lastFrameTime;
  lastFrameTime = timestamp;
  
  // 2. 基于实际时间计算应该处理的字符数
  const targetFrameDuration = Math.floor(1000 / 60); // 理想帧时长
  charAccumulator += (frameDuration / targetFrameDuration) * charsPerFrame;
  const charsToProcess = Math.floor(charAccumulator);

  if (charsToProcess > 0) {
    charAccumulator -= charsToProcess;
    // 处理字符...
  }
}

这种基于时间的实现有以下优势:

  1. 即使在后台页面帧率下降,动画速度也能保持一致
  2. 当回到前台时,会自动"补偿"之前的延迟

3. 改进版本:增加页面可见性处理

以下是一个更健壮的实现方案,增加了对页面可见性的处理:

const createSmoothMessage = (params: {
  onTextUpdate: (delta: string, text: string) => void;
  startSpeed?: number;
}) => {
  let buffer = '';
  let outputQueue: string[] = [];
  let isAnimationActive = false;
  let animationFrameId: number | null = null;
  let lastFrameTime = 0;
  let charAccumulator = 0;
  
  // 添加计时器相关变量
  let lastProcessTime = 0;
  let intervalId: number | null = null;

  // 页面可见性变化处理
  const handleVisibilityChange = () => {
    if (document.hidden) {
      // 页面进入后台,切换到 setInterval
      if (animationFrameId !== null) {
        cancelAnimationFrame(animationFrameId);
        animationFrameId = null;
      }
      
      if (isAnimationActive && !intervalId) {
        lastProcessTime = Date.now();
        intervalId = window.setInterval(updateTextInterval, 1000 / 30); // 30fps in background
      }
    } else {
      // 页面回到前台,切换回 requestAnimationFrame
      if (intervalId !== null) {
        clearInterval(intervalId);
        intervalId = null;
      }
      
      if (isAnimationActive) {
        lastFrameTime = 0; // 重置帧时间
        animationFrameId = requestAnimationFrame(updateText);
      }
    }
  };

  // 后台处理文本的函数
  const updateTextInterval = () => {
    const now = Date.now();
    const duration = now - lastProcessTime;
    lastProcessTime = now;

    const targetFrameDuration = 1000 / 60;
    charAccumulator += (duration / targetFrameDuration) * (params.startSpeed || 4);
    const charsToProcess = Math.floor(charAccumulator);

    if (charsToProcess > 0) {
      charAccumulator -= charsToProcess;
      const actualChars = Math.min(charsToProcess, outputQueue.length);
      const charsToAdd = outputQueue.splice(0, actualChars).join('');
      buffer += charsToAdd;
      params.onTextUpdate(charsToAdd, buffer);
    }

    if (outputQueue.length === 0) {
      isAnimationActive = false;
      if (intervalId !== null) {
        clearInterval(intervalId);
        intervalId = null;
      }
    }
  };

  // 前台动画更新函数
  const updateText = (timestamp: number) => {
    if (!isAnimationActive) {
      animationFrameId = null;
      return;
    }

    if (!lastFrameTime) lastFrameTime = timestamp;
    
    const frameDuration = timestamp - lastFrameTime;
    lastFrameTime = timestamp;
    
    const targetFrameDuration = Math.floor(1000 / 60);
    charAccumulator += (frameDuration / targetFrameDuration) * (params.startSpeed || 4);
    const charsToProcess = Math.floor(charAccumulator);

    if (charsToProcess > 0) {
      charAccumulator -= charsToProcess;
      const actualChars = Math.min(charsToProcess, outputQueue.length);
      const charsToAdd = outputQueue.splice(0, actualChars).join('');
      buffer += charsToAdd;
      params.onTextUpdate(charsToAdd, buffer);
    }

    if (outputQueue.length > 0) {
      animationFrameId = requestAnimationFrame(updateText);
    } else {
      isAnimationActive = false;
      animationFrameId = null;
    }
  };

  // 启动动画
  const startAnimation = () => {
    if (isAnimationActive) return;
    
    isAnimationActive = true;
    lastFrameTime = 0;
    charAccumulator = 0;

    if (document.hidden) {
      lastProcessTime = Date.now();
      intervalId = window.setInterval(updateTextInterval, 1000 / 30);
    } else {
      animationFrameId = requestAnimationFrame(updateText);
    }
  };

  // 注册页面可见性变化监听
  document.addEventListener('visibilitychange', handleVisibilityChange);

  return {
    destroy: () => {
      document.removeEventListener('visibilitychange', handleVisibilityChange);
      if (animationFrameId !== null) cancelAnimationFrame(animationFrameId);
      if (intervalId !== null) clearInterval(intervalId);
    },
    isAnimationActive: () => isAnimationActive,
    pushToQueue: (text: string) => {
      outputQueue.push(...text.split(''));
    },
    startAnimation,
    stopAnimation: () => {
      isAnimationActive = false;
      if (animationFrameId !== null) {
        cancelAnimationFrame(animationFrameId);
        animationFrameId = null;
      }
      if (intervalId !== null) {
        clearInterval(intervalId);
        intervalId = null;
      }
    }
  };
};

4. 关键改进点

  1. 双重动画系统

    • 前台使用 requestAnimationFrame
    • 后台使用 setInterval
    • 自动在两种模式间切换
  2. 页面可见性处理

    document.addEventListener('visibilitychange', handleVisibilityChange);
    • 监听页面可见性变化
    • 根据状态切换动画系统
  3. 时间补偿机制

    const duration = now - lastProcessTime;
    charAccumulator += (duration / targetFrameDuration) * speed;
    • 精确计算时间间隔
    • 保证动画速度的一致性
  4. 资源清理

    destroy: () => {
      document.removeEventListener('visibilitychange', handleVisibilityChange);
      if (animationFrameId !== null) cancelAnimationFrame(animationFrameId);
      if (intervalId !== null) clearInterval(intervalId);
    }
    • 完善的资源清理机制
    • 防止内存泄漏

这种实现方式能够保证:

  • 在前台时保持流畅的动画效果
  • 在后台时继续处理文本输出
  • 切换前后台时保持动画速度的一致性
  • 有效管理系统资源

这样的设计让动画系统在各种场景下都能保持稳定的表现,提供了更好的用户体验。

Copy link

vercel bot commented Feb 15, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
lobe-chat-preview ✅ Ready (Inspect) Visit Preview 💬 Add feedback Feb 28, 2025 0:42am

@arvinxx
Copy link
Contributor

arvinxx commented Feb 16, 2025

取消后段加速输出,这个可以讨论下,个人感觉如果输出速度匹配阅读速度时,突然加速完全是破坏体验的。

其实这里的问题是不一次输出完没法 save content 到 message 里。或者更合适的方案是不做加速逻辑,一次性全部更新掉?

@lobehubbot
Copy link
Member

Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿


Cancel the acceleration output in the latter section. This can be discussed. I personally feel that if the output speed matches the reading speed, sudden acceleration is completely destroying the experience.

In fact, the problem here is that you cannot save content into the message if you output it once. Or is a more suitable solution to not do acceleration logic and update them all at once?

@sxjeru
Copy link
Contributor Author

sxjeru commented Feb 16, 2025

一次性全部输出确实不错,既简单又符合直觉。不过输出时最好关闭页面自动下滑到底部。

今天测试了很久,感觉页面自动滚到底还是有问题,常常不会自动跟随,有待进一步优化。

输出效果还是不错的。

@lobehubbot
Copy link
Member

Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿


The output at one time is really good, both simple and intuitive. However, it is best to close the page and automatically slide to the bottom when outputting.

I have been testing it for a long time today, and I feel that there is still a problem with the page automatically rolling to the end. I often don’t follow it automatically and need further optimization.

@sxjeru
Copy link
Contributor Author

sxjeru commented Feb 16, 2025

英文二倍速显示后,英文的输出速度与中文基本保持一致,在中英文混杂时会有明显感觉。

这里的输出速度是指观感上打字机从左往右刷新的速度,吐字符的速度。

default.mp4

@lobehubbot
Copy link
Member

Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿


After the English double speed is displayed, the English output speed is basically the same as that in Chinese, and there will be obvious feelings in the situation shown in the figure.

The output speed here refers to the speed of the typewriter refreshing from left to right and the speed of the character ejaculation on the visual sense.

image

@arvinxx
Copy link
Contributor

arvinxx commented Feb 20, 2025

@sxjeru 看下这个视频的效果:

google.mp4

很显然能看出来 前端 100字/s 的输出速度是远远小于模型输出的。

我觉得 @memset0 说的思路很对。我们需要的是 「平滑输出」,而不是限制模型速度。

之前有个老哥提过一个想法: #1197 ,是不是可以重新拿回来考虑下

@sxjeru
Copy link
Contributor Author

sxjeru commented Feb 21, 2025

调整了一下代码,现在用 Gemini 等比较快的模型,会自动加速输出了。也有一些问题:

  1. speed 参数不再有用,目前是自适应调节速度,看看有无必要把这个作为输出速度上限。
  2. 输出结尾由于队列中内容减少,输出速度会放慢,导致会在 api 响应结束后的 2-5s 内输出完毕,而不是瞬间输出所有剩余内容。应该无伤大雅?
  3. 每次转到后台后再转回来,输出速度会从 0 开始起步,不过观感上不是很明显,或许也符合直觉。
  4. 输出内容太快时,观感上帧率会变低,也就是有些卡顿,不清楚是不是咱的设备问题。并且因为速度是每帧调节,帧率低会导致速度变化更不平滑,加剧卡顿感。

对于 openrouter 这种流式返回非常快又非常零碎的 api,还是起不到平滑输出的效果。

对于 Qwen 这种慢速 api,效果还是可以的。

@lobehubbot
Copy link
Member

Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿


After adjusting the code, I now use relatively fast models such as Gemini to automatically accelerate the output. There are also some problems:

  1. The speed parameter is no longer useful, and it is currently adaptive to adjust the speed. See if it is necessary to use this as the output speed upper limit.
  2. At the end of the output, the output speed will slow down due to the decrease in content in the queue, resulting in the output completion within 2-5s after the API response, rather than instantly outputting all remaining content. Should it be harmless?
  3. Every time you turn to the background and then turn back, the output speed will start from 0, but the visual effect is not very obvious, and it may also be in line with intuition.
  4. When the output content is too fast, the frame rate will become lower in appearance, which means it is a bit stuttering. It is not clear whether it is a problem with our device. And because the speed is adjusted per frame, the low frame rate will cause the speed change to be more unsmooth, aggravating the sense of lag.

@arvinxx
Copy link
Contributor

arvinxx commented Feb 23, 2025

speed 参数不再有用,目前是自适应调节速度,看看有无必要把这个作为输出速度上限。

不需要,speed 参数之前是因为没有自适应速度调节,所以才不得不加的参数。应该可以直接去掉?或者看下是否在哪些场景会有希望限速的诉求

输出结尾由于队列中内容减少,输出速度会放慢,导致会在 api 响应结束后的 2-5s 内输出完毕,而不是瞬间输出所有剩余内容。应该无伤大雅?

问题不大,反正现在也是加速输出的,如果输出速度更快的话,算是体验提升。除非新的输出速度明显比之前的慢了很多,才有必要这么做。

@lobehubbot
Copy link
Member

Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿


The speed parameter is no longer useful, currently it is adaptive to adjust the speed, to see if it is necessary to use this as the output speed upper limit.

No, the speed parameter was previously due to the lack of adaptive speed adjustment, so the parameters had to be added. Should it be removed directly? Or see if there is a hope for speed limit in which scenarios

At the end of the output, the output speed will slow down due to the decrease in content in the queue, resulting in the output completion within 2-5s after the API response, rather than instantly outputting all the remaining content. Should it be harmless?

There is no big problem. Anyway, it is accelerating output now. If the output speed is faster, it will be considered an improvement in experience. This is necessary unless the new output speed is significantly slower than before.

// 更平滑的速度调整
const targetSpeed = Math.max(speed, outputQueue.length);
// 根据队列长度变化调整速度变化率
const speedChangeRate = Math.abs(outputQueue.length - lastQueueLength) * 0.0008 + 0.005;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里的 0.0008 和 0.005 是什么意思?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0.0008 对应输出速度的变化速度,越小速度变化越平缓。0.005 是速度变化最小值,避免速度变化的太慢。

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个最好抽取来变成常数,并且加上注释。以及为什么它的单位是 0.000 ? 是有什么含义吗?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个其实我也不太清楚,两个数都是 AI 给出的,我这边测试效果不错就没有改动。

@arvinxx
Copy link
Contributor

arvinxx commented Feb 23, 2025

单测挂了要修一下?

@lobehubbot
Copy link
Member

Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿


If you fail a single test, you need to repair it?

@sxjeru
Copy link
Contributor Author

sxjeru commented Feb 27, 2025

只从单测结果看,貌似当 reason 有很多内容,而 text 内容很短时,输出完 text 后就会结束,不再输出所有 reason 。
但是测试结果并非这样,是能正常输出所有内容的。

另外这个单测有概率失败,可能因为速度平滑调整的不确定性,很难定位 text 的 "hi" 会在何时开始输出,考虑把它删了。

现在 text 有内容时就会直接输出,而不是等待 reason 输出完后再继续。

Copy link
Contributor

@arvinxx arvinxx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我先合并解决后台输出的问题吧,感觉目前的 smoothing 算法仍然不是最优解,后续要继续看下高速 token 输出下的优化

@arvinxx arvinxx merged commit 7a84ad9 into lobehub:main Feb 28, 2025
12 of 15 checks passed
@lobehubbot
Copy link
Member

❤️ Great PR @sxjeru ❤️

The growth of project is inseparable from user feedback and contribution, thanks for your contribution! If you are interesting with the lobehub developer community, please join our discord and then dm @arvinxx or @canisminor1990. They will invite you to our private developer channel. We are talking about the lobe-chat development or sharing ai newsletter around the world.
项目的成长离不开用户反馈和贡献,感谢您的贡献! 如果您对 LobeHub 开发者社区感兴趣,请加入我们的 discord,然后私信 @arvinxx@canisminor1990。他们会邀请您加入我们的私密开发者频道。我们将会讨论关于 Lobe Chat 的开发,分享和讨论全球范围内的 AI 消息。

github-actions bot pushed a commit that referenced this pull request Feb 28, 2025
### [Version&nbsp;1.66.4](v1.66.3...v1.66.4)
<sup>Released on **2025-02-28**</sup>

#### 💄 Styles

- **misc**: Optimize smooth output.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### Styles

* **misc**: Optimize smooth output, closes [#5824](#5824) ([7a84ad9](7a84ad9))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
@lobehubbot
Copy link
Member

🎉 This PR is included in version 1.66.4 🎉

The release is available on:

Your semantic-release bot 📦🚀

github-actions bot pushed a commit to bentwnghk/lobe-chat that referenced this pull request Feb 28, 2025
### [Version&nbsp;1.107.2](v1.107.1...v1.107.2)
<sup>Released on **2025-02-28**</sup>

#### 💄 Styles

- **misc**: Improve portal style, Optimize smooth output.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### Styles

* **misc**: Improve portal style, closes [lobehub#6588](https://github.com/bentwnghk/lobe-chat/issues/6588) ([55b5416](55b5416))
* **misc**: Optimize smooth output, closes [lobehub#5824](https://github.com/bentwnghk/lobe-chat/issues/5824) ([7a84ad9](7a84ad9))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
released size:L This PR changes 100-499 lines, ignoring generated files.
Projects
None yet
5 participants