Compare commits

...

3 Commits

Author SHA1 Message Date
semantic-release-bot 41c554d748 🔖 chore(release): v2.0.0-next.70 [skip ci]
## [Version 2.0.0-next.70](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.69...v2.0.0-next.70)
<sup>Released on **2025-11-17**</sup>

<br/>

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

</details>

<div align="right">

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

</div>
2025-11-17 04:13:23 +00:00
LobeHub Bot 4e4933d861 🌐 chore: translate non-English comments to English in packages/types and packages/web-crawler (#10267)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-17 12:01:06 +08:00
René Wang a5bb31b844 ️ perf: improve Chat Screenshot and fix image geneartion (#10261)
* feat: Support narrow mode export

* feat: Replace `modern-screenshot` with `snapDom`

* feat: Add CORS proxy
2025-11-17 12:00:44 +08:00
21 changed files with 199 additions and 104 deletions
+17
View File
@@ -2,6 +2,23 @@
# Changelog
## [Version 2.0.0-next.70](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.69...v2.0.0-next.70)
<sup>Released on **2025-11-17**</sup>
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.69](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.68...v2.0.0-next.69)
<sup>Released on **2025-11-17**</sup>
+5
View File
@@ -330,6 +330,11 @@
"screenshot": "Screenshot",
"settings": "Export Settings",
"text": "Text",
"widthMode": {
"label": "Width Mode",
"narrow": "Narrow",
"wide": "Wide"
},
"withBackground": "Include Background Image",
"withFooter": "Include Footer",
"withPluginInfo": "Include Plugin Information",
+5
View File
@@ -330,6 +330,11 @@
"screenshot": "截图",
"settings": "导出设置",
"text": "文本",
"widthMode": {
"label": "宽度模式",
"narrow": "窄屏模式",
"wide": "宽屏模式"
},
"withBackground": "包含背景图片",
"withFooter": "包含页脚",
"withPluginInfo": "包含插件信息",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/lobehub",
"version": "2.0.0-next.69",
"version": "2.0.0-next.70",
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
"keywords": [
"framework",
@@ -191,6 +191,7 @@
"@vercel/speed-insights": "^1.2.0",
"@virtuoso.dev/masonry": "^1.3.5",
"@xterm/xterm": "^5.5.0",
"@zumer/snapdom": "^1.9.14",
"ahooks": "^3.9.6",
"antd": "^5.28.1",
"antd-style": "^3.7.1",
@@ -228,7 +229,6 @@
"marked": "^16.4.2",
"mdast-util-to-markdown": "^2.1.2",
"model-bank": "workspace:*",
"modern-screenshot": "^4.6.6",
"nanoid": "^5.1.6",
"next": "^16.0.3",
"next-auth": "5.0.0-beta.30",
+8 -8
View File
@@ -17,16 +17,16 @@ export interface LobeAgentChatConfig {
enableMaxTokens?: boolean;
/**
* 是否开启流式输出
* Whether to enable streaming output
*/
enableStreaming?: boolean;
/**
* 是否开启推理
* Whether to enable reasoning
*/
enableReasoning?: boolean;
/**
* 自定义推理强度
* Custom reasoning effort level
*/
enableReasoningEffort?: boolean;
reasoningBudgetToken?: number;
@@ -34,25 +34,25 @@ export interface LobeAgentChatConfig {
gpt5ReasoningEffort?: 'minimal' | 'low' | 'medium' | 'high';
gpt5_1ReasoningEffort?: 'none' | 'low' | 'medium' | 'high';
/**
* 输出文本详细程度控制
* Output text verbosity control
*/
textVerbosity?: 'low' | 'medium' | 'high';
thinking?: 'disabled' | 'auto' | 'enabled';
thinkingBudget?: number;
/**
* 禁用上下文缓存
* Disable context caching
*/
disableContextCaching?: boolean;
/**
* 历史消息条数
* Number of historical messages
*/
historyCount?: number;
/**
* 开启历史记录条数
* Enable historical message count
*/
enableHistoryCount?: boolean;
/**
* 历史消息长度压缩阈值
* Enable history message compression threshold
*/
enableCompressHistory?: boolean;
+4 -4
View File
@@ -56,12 +56,12 @@ export interface LobeDocument {
source: string;
/**
* 文档来源类型
* Document source type
*/
sourceType: DocumentSourceType;
/**
* 文档标题 (如果可用)。
* Document title (if available)
*/
title?: string;
@@ -168,12 +168,12 @@ export enum DocumentSourceType {
API = 'api',
/**
* 编辑器创建的文档
* Document created in editor
*/
EDITOR = 'editor',
/**
* 本地或上传的文件
* Local or uploaded file
*/
FILE = 'file',
+9 -9
View File
@@ -2,15 +2,15 @@
import type { ILobeAgentRuntimeErrorType } from '@lobechat/model-runtime';
export const ChatErrorType = {
// ******* 业务错误语义 ******* //
// ******* Business Error Semantics ******* //
InvalidAccessCode: 'InvalidAccessCode', // is in valid password
InvalidClerkUser: 'InvalidClerkUser', // is not Clerk User
FreePlanLimit: 'FreePlanLimit', // is not Clerk User
SubscriptionPlanLimit: 'SubscriptionPlanLimit', // 订阅用户超限
SubscriptionKeyMismatch: 'SubscriptionKeyMismatch', // 订阅 key 不匹配
SubscriptionPlanLimit: 'SubscriptionPlanLimit', // Subscription user limit exceeded
SubscriptionKeyMismatch: 'SubscriptionKeyMismatch', // Subscription key mismatch
SupervisorDecisionFailed: 'SupervisorDecisionFailed', // 主持人决策失败
SupervisorDecisionFailed: 'SupervisorDecisionFailed', // Supervisor decision failed
InvalidUserKey: 'InvalidUserKey', // is not valid User key
CreateMessageError: 'CreateMessageError',
@@ -18,20 +18,20 @@ export const ChatErrorType = {
* @deprecated
*/
NoOpenAIAPIKey: 'NoOpenAIAPIKey',
OllamaServiceUnavailable: 'OllamaServiceUnavailable', // 未启动/检测到 Ollama 服务
OllamaServiceUnavailable: 'OllamaServiceUnavailable', // Ollama service not started/detected
PluginFailToTransformArguments: 'PluginFailToTransformArguments',
UnknownChatFetchError: 'UnknownChatFetchError',
SystemTimeNotMatchError: 'SystemTimeNotMatchError',
// ******* 客户端错误 ******* //
// ******* Client Errors ******* //
BadRequest: 400,
Unauthorized: 401,
Forbidden: 403,
ContentNotFound: 404, // 没找到接口
MethodNotAllowed: 405, // 不支持
ContentNotFound: 404, // Endpoint not found
MethodNotAllowed: 405, // Method not supported
TooManyRequests: 429,
// ******* 服务端错误 ******* //InvalidPluginArgumentsTransform
// ******* Server Errors ******* //InvalidPluginArgumentsTransform
InternalServerError: 500,
BadGateway: 502,
ServiceUnavailable: 503,
+5 -5
View File
@@ -84,7 +84,7 @@ export const HotkeyGroupEnum = {
export const HotkeyScopeEnum = {
Chat: 'chat',
Files: 'files',
// 默认全局注册的快捷键 scope
// Default globally registered hotkey scope
// https://react-hotkeys-hook.vercel.app/docs/documentation/hotkeys-provider
Global: 'global',
@@ -96,13 +96,13 @@ export type HotkeyGroupId = (typeof HotkeyGroupEnum)[keyof typeof HotkeyGroupEnu
export type HotkeyScopeId = (typeof HotkeyScopeEnum)[keyof typeof HotkeyScopeEnum];
export interface HotkeyItem {
// 快捷键分组用于展示
// Hotkey grouping for display purposes
group: HotkeyGroupId;
id: HotkeyId;
keys: string;
// 是否为不可编辑的快捷键
// Whether the hotkey is non-editable
nonEditable?: boolean;
// 快捷键作用域
// Hotkey scope
scopes?: HotkeyScopeId[];
}
@@ -119,7 +119,7 @@ export interface DesktopHotkeyItem {
id: DesktopHotkeyId;
keys: string;
// 是否为不可编辑的快捷键
// Whether the hotkey is non-editable
nonEditable?: boolean;
}
+2 -2
View File
@@ -26,11 +26,11 @@ export interface ImportMessage {
createdAt: number;
error?: ChatMessageError;
// 扩展字段
// Extended fields
extra?: {
model?: string;
provider?: string;
// 翻译
// Translation
translate?: ChatTranslate | false | null;
// TTS
tts?: ChatTTS;
@@ -109,8 +109,8 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
activeBranchIndex?: number;
activeColumn?: boolean;
/**
* 消息折叠状态
* true: 折叠, false/undefined: 展开
* Message collapse state
* true: collapsed, false/undefined: expanded
*/
collapsed?: boolean;
compare?: boolean;
+1 -1
View File
@@ -112,7 +112,7 @@ export const ChatToolPayloadSchema = z.object({
});
/**
* 聊天消息错误对象
* Chat message error object
*/
export interface ChatMessagePluginError {
body?: any;
+3 -3
View File
@@ -2,11 +2,11 @@ import { z } from 'zod';
export const LobeMetaDataSchema = z.object({
/**
* 角色头像
* Character avatar
*/
avatar: z.string().optional(),
/**
* 背景色
* Background color
*/
backgroundColor: z.string().optional(),
description: z.string().optional(),
@@ -17,7 +17,7 @@ export const LobeMetaDataSchema = z.object({
tags: z.array(z.string()).optional(),
/**
* 名称
* Name
*/
title: z.string().optional(),
});
+1 -1
View File
@@ -1,6 +1,6 @@
import type { BaseDataModel } from '../meta';
// 类型定义
// Type definitions
export type TimeGroupId =
| 'today'
| 'yesterday'
+4 -4
View File
@@ -16,7 +16,7 @@ export interface CrawlErrorResult {
}
export interface FilterOptions {
// 是否启用Readability
// Whether to enable Readability
enableReadability?: boolean;
pureText?: boolean;
@@ -34,12 +34,12 @@ export type CrawlImpl<Params = object> = (
) => Promise<CrawlSuccessResult | undefined>;
export interface CrawlUrlRule {
// 内容过滤配置(可选)
// Content filtering configuration (optional)
filterOptions?: FilterOptions;
impls?: CrawlImplType[];
// URL匹配模式,仅支持正则表达式
// URL matching pattern, only supports regular expressions
urlPattern: string;
// URL转换模板(可选),如果提供则进行URL转换
// URL transformation template (optional), performs URL conversion if provided
urlTransform?: string;
}
@@ -1,9 +1,9 @@
import { copyImageToClipboard, sanitizeSVGContent } from '@lobechat/utils/client';
import { Button, Dropdown, Tooltip } from '@lobehub/ui';
import { snapdom } from '@zumer/snapdom';
import { App, Space } from 'antd';
import { css, cx } from 'antd-style';
import { CopyIcon, DownloadIcon } from 'lucide-react';
import { domToPng } from 'modern-screenshot';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
@@ -41,12 +41,29 @@ const SVGRenderer = ({ content }: SVGRendererProps) => {
const sanitizedContent = useMemo(() => sanitizeSVGContent(content), [content]);
const generatePng = async () => {
return domToPng(document.querySelector(`#${DOM_ID}`) as HTMLDivElement, {
features: {
// 不启用移除控制符,否则会导致 safari emoji 报错
removeControlCharacter: false,
},
const blob = await snapdom.toBlob(document.querySelector(`#${DOM_ID}`) as HTMLDivElement, {
scale: 2,
type: 'png',
});
if (!blob) {
throw new Error('Failed to generate PNG blob');
}
// Convert blob to data URL
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('FileReader result is not a string'));
}
});
reader.addEventListener('error', () =>
reject(reader.error || new Error('Failed to read blob as data URL')),
);
reader.readAsDataURL(blob);
});
};
@@ -19,7 +19,7 @@ import { useStyles } from './style';
import { FieldType } from './type';
const Preview = memo<FieldType & { title?: string }>(
({ title, withSystemRole, withBackground, withFooter }) => {
({ title, withSystemRole, withBackground, withFooter, widthMode }) => {
const [model, plugins, systemRole] = useAgentStore((s) => [
agentSelectors.currentAgentModel(s),
agentSelectors.displayableAgentPlugins(s),
@@ -34,7 +34,7 @@ const Preview = memo<FieldType & { title?: string }>(
const { t } = useTranslation('chat');
const { styles } = useStyles(withBackground);
const { styles: containerStyles } = useContainerStyles();
const { styles: containerStyles } = useContainerStyles(widthMode);
const displayTitle = isInbox ? t('inbox.title') : title;
const displayDesc = isInbox ? t('inbox.desc') : description;
+15 -1
View File
@@ -14,10 +14,11 @@ import { sessionMetaSelectors } from '@/store/session/selectors';
import { useStyles } from '../style';
import Preview from './Preview';
import { FieldType } from './type';
import { FieldType, WidthMode } from './type';
const DEFAULT_FIELD_VALUE: FieldType = {
imageType: ImageType.JPG,
widthMode: WidthMode.Wide,
withBackground: true,
withFooter: true,
withPluginInfo: false,
@@ -34,7 +35,20 @@ const ShareImage = memo<{ mobile?: boolean }>(() => {
title: currentAgentTitle,
});
const { loading: copyLoading, onCopy } = useImgToClipboard();
const widthModeOptions = [
{ label: t('shareModal.widthMode.wide'), value: WidthMode.Wide },
{ label: t('shareModal.widthMode.narrow'), value: WidthMode.Narrow },
];
const settings: FormItemProps[] = [
{
children: <Segmented options={widthModeOptions} />,
label: t('shareModal.widthMode.label'),
layout: 'horizontal',
minWidth: undefined,
name: 'widthMode',
},
{
children: <Switch />,
label: t('shareModal.withSystemRole'),
@@ -1,7 +1,13 @@
import { ImageType } from '@/hooks/useScreenshot';
export enum WidthMode {
Narrow = 'narrow',
Wide = 'wide'
}
export type FieldType = {
imageType: ImageType;
widthMode: WidthMode;
withBackground: boolean;
withFooter: boolean;
withPluginInfo: boolean;
+36 -26
View File
@@ -1,36 +1,46 @@
import { createStyles } from 'antd-style';
export const useContainerStyles = createStyles(({ css, token, stylish, cx, responsive }) => ({
preview: cx(
stylish.noScrollbar,
css`
overflow: hidden scroll;
import { WidthMode } from './ShareImage/type';
width: 100%;
max-height: 70dvh;
border: 1px solid ${token.colorBorder};
border-radius: ${token.borderRadiusLG}px;
export const useContainerStyles = createStyles(
({ css, token, stylish, cx, responsive }, widthMode?: WidthMode) => {
const isNarrow = widthMode === WidthMode.Narrow;
background: ${token.colorBgLayout};
return {
preview: cx(
stylish.noScrollbar,
css`
overflow: hidden scroll;
/* stylelint-disable selector-class-pattern */
.react-pdf__Document *,
.react-pdf__Page * {
pointer-events: none;
}
/* stylelint-enable selector-class-pattern */
width: 100%;
max-width: ${isNarrow ? '480px' : 'none'};
max-height: 70dvh;
margin: ${isNarrow ? '0 auto' : '0'};
border: 1px solid ${token.colorBorder};
border-radius: ${token.borderRadiusLG}px;
::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
background: ${token.colorBgLayout};
${responsive.mobile} {
max-height: 40dvh;
}
`,
),
}));
/* stylelint-disable selector-class-pattern */
.react-pdf__Document *,
.react-pdf__Page * {
pointer-events: none;
}
/* stylelint-enable selector-class-pattern */
::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
${responsive.mobile} {
max-height: 40dvh;
}
`,
),
};
},
);
export const useStyles = createStyles(({ responsive, token, css }) => ({
body: css`
+44 -28
View File
@@ -1,6 +1,6 @@
import type { SegmentedProps } from '@lobehub/ui';
import { snapdom } from '@zumer/snapdom';
import dayjs from 'dayjs';
import { domToJpeg, domToPng, domToSvg, domToWebp } from 'modern-screenshot';
import { useCallback, useState } from 'react';
import { BRANDING_NAME } from '@/const/branding';
@@ -40,26 +40,6 @@ export const getImageUrl = async ({
imageType: ImageType;
width?: number;
}) => {
let screenshotFn: any;
switch (imageType) {
case ImageType.JPG: {
screenshotFn = domToJpeg;
break;
}
case ImageType.PNG: {
screenshotFn = domToPng;
break;
}
case ImageType.SVG: {
screenshotFn = domToSvg;
break;
}
case ImageType.WEBP: {
screenshotFn = domToWebp;
break;
}
}
const dom: HTMLDivElement = document.querySelector(id) as HTMLDivElement;
let copy: HTMLDivElement = dom;
@@ -69,18 +49,54 @@ export const getImageUrl = async ({
document.body.append(copy);
}
const dataUrl = await screenshotFn(width ? copy : dom, {
features: {
// 不启用移除控制符,否则会导致 safari emoji 报错
removeControlCharacter: false,
},
const baseOptions = {
scale: 2,
width,
});
};
let blob: Blob;
if (imageType === ImageType.SVG) {
// For SVG, we need to use the full snapdom API to get the raw SVG string
const result = await snapdom(width ? copy : dom, baseOptions);
const svgString = result.toRaw();
blob = new Blob([svgString], { type: 'image/svg+xml' });
} else {
// For raster formats, use toBlob directly with type option
const blobType = (imageType === ImageType.JPG ? 'jpg' : imageType) as 'png' | 'jpg' | 'webp';
const blobResult = await snapdom.toBlob(width ? copy : dom, {
type: blobType,
useProxy: 'https://proxy.corsfix.com/?',
});
if (!blobResult) {
throw new Error('Failed to generate blob from snapdom');
}
blob = blobResult;
}
if (width && copy) copy?.remove();
return dataUrl;
if (!blob) {
throw new Error('Blob is undefined');
}
// Convert blob to data URL using FileReader
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('FileReader result is not a string'));
}
});
reader.addEventListener('error', () =>
reject(reader.error || new Error('Failed to read blob as data URL')),
);
reader.readAsDataURL(blob);
});
};
export const useScreenshot = ({
+5
View File
@@ -361,6 +361,11 @@ export default {
screenshot: '截图',
settings: '导出设置',
text: '文本',
widthMode: {
label: '宽度模式',
narrow: '窄屏模式',
wide: '宽屏模式',
},
withBackground: '包含背景图片',
withFooter: '包含页脚',
withPluginInfo: '包含插件信息',