Commit 8784b622 by Archer Committed by GitHub

fix: svg (#7094)

* fix: svg

* doc

* doc
parent a81d6a83
...@@ -59,3 +59,109 @@ description: 'FastGPT V4.15.0-beta4 更新说明' ...@@ -59,3 +59,109 @@ description: 'FastGPT V4.15.0-beta4 更新说明'
1. 插件服务从旧 `runtime` 结构调整为 pnpm workspace monorepo,拆分为 HTTP 服务入口、领域模型、用例、API adapter、基础设施、SDK 和 CLI。 1. 插件服务从旧 `runtime` 结构调整为 pnpm workspace monorepo,拆分为 HTTP 服务入口、领域模型、用例、API adapter、基础设施、SDK 和 CLI。
2. 将 app API 接口全部用 zod schema 编写并生成文档。 2. 将 app API 接口全部用 zod schema 编写并生成文档。
3. 及时处理 worker 内图片,不再存留 base64,降低内存消耗。 3. 及时处理 worker 内图片,不再存留 base64,降低内存消耗。
## FAQ
如果出现 Mongo sync index error,并且是提示:
```shell
modelName: 'chat_item_responses',
error: MongoServerError: Index build failed: a45ca5d9-8429-4602-80bc-312a22b92687: Collection fastgpt.chat_item_responses ( f2a417fe-6e7c-4ec2-8947-d8003755462f ) :: caused by :: E11000 duplicate key error collection: fastgpt.chat_item_responses index: appId_1_chatId_1_chatItemDataId_1_data.id_1 dup key: { appId: ObjectId('69c9110790de745f57e93919'), chatId: "jzt1JCcaoy4kC67hdhQlzUob", chatItemDataId: "xa0f1AMFr9CWj9uVC9TqGtc0", data.id: "jTUTS9j67N4CyZ17" }
```
可以进入 mongo shell,执行以下命令来清楚重复的数据。这里仅删除对话中每个节点的运行详情结果,无风险,可能是过去某次出现异常导致重复。
```shell
use fastgpt
const dryRun = true; // 确认后改成 false
const batchSize = 500;
const col = db.chat_item_responses;
let duplicateGroups = 0;
let duplicateDocs = 0;
let removableDocs = 0;
let deletedDocs = 0;
let pendingIds = [];
function flushDeletes() {
if (pendingIds.length === 0) return;
const ids = pendingIds;
pendingIds = [];
if (dryRun) {
print(`[dry-run] would delete ${ids.length} old docs`);
return;
}
const res = col.deleteMany({ _id: { $in: ids } });
deletedDocs += res.deletedCount;
print(`[delete] deleted ${res.deletedCount} old docs, total=${deletedDocs}`);
}
const cursor = col.aggregate(
[
{
$group: {
_id: {
appId: "$appId",
chatId: "$chatId",
chatItemDataId: "$chatItemDataId",
dataId: "$data.id"
},
count: { $sum: 1 }
}
},
{ $match: { count: { $gt: 1 } } },
{ $sort: { count: -1 } }
],
{ allowDiskUse: true }
);
while (cursor.hasNext()) {
const group = cursor.next();
duplicateGroups += 1;
duplicateDocs += group.count;
removableDocs += group.count - 1;
const key = group._id;
const filter = {
appId: key.appId,
chatId: key.chatId,
chatItemDataId: key.chatItemDataId,
"data.id": key.dataId
};
const docs = col
.find(filter, { _id: 1, time: 1 })
.sort({ _id: -1 })
.toArray();
const keepId = docs[0]?._id;
const removeIds = docs.slice(1).map((doc) => doc._id);
printjson({
group: duplicateGroups,
count: group.count,
keepId,
removeIds
});
pendingIds.push(...removeIds);
if (pendingIds.length >= batchSize) {
flushDeletes();
}
}
flushDeletes();
printjson({
dryRun,
duplicateGroups,
duplicateDocs,
removableDocs,
deletedDocs
});
```
...@@ -147,8 +147,8 @@ ...@@ -147,8 +147,8 @@
"content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/system-tool-development.en.mdx": "2026-06-09T16:03:58+08:00", "content/plugin/system-tool-development.en.mdx": "2026-06-09T16:03:58+08:00",
"content/plugin/system-tool-development.mdx": "2026-06-09T16:03:58+08:00", "content/plugin/system-tool-development.mdx": "2026-06-09T16:03:58+08:00",
"content/self-host/config/env.en.mdx": "2026-05-27T12:17:46+08:00", "content/self-host/config/env.en.mdx": "2026-06-10T19:02:59+08:00",
"content/self-host/config/env.mdx": "2026-05-27T12:17:46+08:00", "content/self-host/config/env.mdx": "2026-06-10T19:02:59+08:00",
"content/self-host/config/json.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/config/json.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/config/json.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/config/json.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00",
...@@ -272,8 +272,8 @@ ...@@ -272,8 +272,8 @@
"content/self-host/upgrading/4-15/41502.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41502.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-28T16:21:09+08:00", "content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41503.mdx": "2026-05-28T16:21:09+08:00", "content/self-host/upgrading/4-15/41503.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41504.en.mdx": "2026-06-07T17:54:48+08:00", "content/self-host/upgrading/4-15/41504.en.mdx": "2026-06-10T19:02:59+08:00",
"content/self-host/upgrading/4-15/41504.mdx": "2026-06-10T17:33:23+08:00", "content/self-host/upgrading/4-15/41504.mdx": "2026-06-10T19:02:59+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
import React, { useEffect, useState } from 'react'; import React, { useEffect, useId, useLayoutEffect, useState } from 'react';
import type { IconProps } from '@chakra-ui/react'; import type { IconProps } from '@chakra-ui/react';
import { Box, Icon } from '@chakra-ui/react'; import { Box, Icon } from '@chakra-ui/react';
import { iconPaths } from './constants'; import { iconPaths } from './constants';
import type { IconNameType } from './type'; import type { IconNameType } from './type';
import { scopeSvgElementIds } from './svgScope';
const iconCache: Record<string, any> = {}; const iconCache: Record<string, any> = {};
const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const MyIcon = ({ name, w = 'auto', h = 'auto', ...props }: { name: IconNameType } & IconProps) => { const MyIcon = ({ name, w = 'auto', h = 'auto', ...props }: { name: IconNameType } & IconProps) => {
const [, setUpdate] = useState(0); const [, setUpdate] = useState(0);
const scopeId = useId().replace(/:/g, '');
useEffect(() => { useEffect(() => {
if (iconCache[name]) { if (iconCache[name]) {
...@@ -26,6 +29,15 @@ const MyIcon = ({ name, w = 'auto', h = 'auto', ...props }: { name: IconNameType ...@@ -26,6 +29,15 @@ const MyIcon = ({ name, w = 'auto', h = 'auto', ...props }: { name: IconNameType
const IconComponent = iconCache[name]; const IconComponent = iconCache[name];
useBrowserLayoutEffect(() => {
const svg = document.querySelector<SVGSVGElement>(
`svg[data-fastgpt-icon-instance="${scopeId}"]`
);
if (!svg) return;
scopeSvgElementIds(svg, scopeId, `${scopeId}-${name}`);
}, [IconComponent, name, scopeId]);
return !!IconComponent ? ( return !!IconComponent ? (
<Icon <Icon
{...IconComponent} {...IconComponent}
...@@ -35,6 +47,7 @@ const MyIcon = ({ name, w = 'auto', h = 'auto', ...props }: { name: IconNameType ...@@ -35,6 +47,7 @@ const MyIcon = ({ name, w = 'auto', h = 'auto', ...props }: { name: IconNameType
verticalAlign={'top'} verticalAlign={'top'}
fill={'currentcolor'} fill={'currentcolor'}
{...props} {...props}
data-fastgpt-icon-instance={scopeId}
/> />
) : ( ) : (
<Box w={w} h={'1px'} /> <Box w={w} h={'1px'} />
......
const svgUrlReferenceReg = /url\((['"]?)#([^)'" ]+)\1\)/g;
const hashOnlyReferenceReg = /^#(.+)$/;
const spaceSeparatedReferenceAttrs = new Set(['aria-labelledby', 'aria-describedby']);
const toScopedSvgId = (scopeId: string, id: string) => {
const prefix = `${scopeId}__`;
return id.startsWith(prefix) ? id : `${prefix}${id}`;
};
export const createScopedSvgIdMap = (ids: string[], scopeId: string) => {
const idMap = new Map<string, string>();
ids.forEach((id) => {
if (!id) return;
idMap.set(id, toScopedSvgId(scopeId, id));
});
return idMap;
};
export const scopeSvgReferenceValue = (value: string, idMap: Map<string, string>) => {
let nextValue = value.replace(svgUrlReferenceReg, (match, quote: string, id: string) => {
const scopedId = idMap.get(id);
return scopedId ? `url(${quote}#${scopedId}${quote})` : match;
});
const hashOnlyMatch = nextValue.match(hashOnlyReferenceReg);
if (hashOnlyMatch) {
const scopedId = idMap.get(hashOnlyMatch[1]);
if (scopedId) {
nextValue = `#${scopedId}`;
}
}
return nextValue;
};
export const scopeSpaceSeparatedSvgReferenceValue = (value: string, idMap: Map<string, string>) => {
return value
.split(/\s+/)
.map((id) => idMap.get(id) || id)
.join(' ');
};
/**
* 为单个内联 SVG 实例生成独立 id 作用域,避免多个相同系统 icon 同屏渲染时
* `url(#id)`、`href="#id"` 等 DOM 级引用串到其他 SVG 实例。
*/
export const scopeSvgElementIds = (svg: SVGSVGElement, scopeId: string, scopeKey = scopeId) => {
if (svg.dataset.fastgptIconScoped === scopeKey) return;
const elements = [svg, ...Array.from(svg.querySelectorAll('*'))];
const rawIds = elements
.map((element) => element.getAttribute('id'))
.filter((id): id is string => !!id);
const idMap = createScopedSvgIdMap(rawIds, scopeId);
if (idMap.size === 0) {
svg.dataset.fastgptIconScoped = scopeKey;
return;
}
elements.forEach((element) => {
const id = element.getAttribute('id');
const scopedId = id ? idMap.get(id) : undefined;
if (scopedId) {
element.setAttribute('id', scopedId);
}
});
elements.forEach((element) => {
Array.from(element.attributes).forEach((attr) => {
if (attr.name === 'id') return;
const nextValue = spaceSeparatedReferenceAttrs.has(attr.name)
? scopeSpaceSeparatedSvgReferenceValue(attr.value, idMap)
: scopeSvgReferenceValue(attr.value, idMap);
if (nextValue !== attr.value) {
element.setAttribute(attr.name, nextValue);
}
});
});
svg.dataset.fastgptIconScoped = scopeKey;
};
...@@ -1226,6 +1226,9 @@ importers: ...@@ -1226,6 +1226,9 @@ importers:
'@types/xml2js': '@types/xml2js':
specifier: ^0.4.14 specifier: ^0.4.14
version: 0.4.14 version: 0.4.14
tsdown:
specifier: 'catalog:'
version: 0.21.10(typescript@6.0.3)
projects/app: projects/app:
dependencies: dependencies:
Subproject commit 58d0509b3a1a6cb5d47340858a4cdfcfc01b51a0 Subproject commit ce87008bf0da48e31e1e74036d1c37e903ea64c9
...@@ -12,9 +12,13 @@ import { getRandomUserAvatar } from '@fastgpt/global/support/user/utils'; ...@@ -12,9 +12,13 @@ import { getRandomUserAvatar } from '@fastgpt/global/support/user/utils';
import { presignVariablesFileUrls } from '@fastgpt/service/core/chat/utils'; import { presignVariablesFileUrls } from '@fastgpt/service/core/chat/utils';
import { InitOutLinkChatQuerySchema } from '@fastgpt/global/openapi/core/chat/outLink/api'; import { InitOutLinkChatQuerySchema } from '@fastgpt/global/openapi/core/chat/outLink/api';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants'; import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
const { chatId, shareId, outLinkUid } = InitOutLinkChatQuerySchema.parse(req.query); const { chatId, shareId, outLinkUid } = parseApiInput({
req,
querySchema: InitOutLinkChatQuerySchema
}).query;
// auth link permission // auth link permission
const { uid, appId } = await authOutLink({ shareId, outLinkUid }); const { uid, appId } = await authOutLink({ shareId, outLinkUid });
......
import { describe, expect, it } from 'vitest';
import {
createScopedSvgIdMap,
scopeSpaceSeparatedSvgReferenceValue,
scopeSvgElementIds,
scopeSvgReferenceValue
} from '@fastgpt/web/components/common/Icon/svgScope';
class FakeSvgElement {
public attributes: { name: string; value: string }[];
public dataset: Record<string, string> = {};
constructor(
attrs: Record<string, string>,
private readonly children: FakeSvgElement[] = []
) {
this.attributes = Object.entries(attrs).map(([name, value]) => ({ name, value }));
}
getAttribute(name: string) {
return this.attributes.find((attr) => attr.name === name)?.value ?? null;
}
setAttribute(name: string, value: string) {
const attr = this.attributes.find((attr) => attr.name === name);
if (attr) {
attr.value = value;
return;
}
this.attributes.push({ name, value });
}
querySelectorAll() {
return this.children;
}
}
describe('svgScope', () => {
it('scopes url and hash references with the generated id map', () => {
const idMap = createScopedSvgIdMap(['paint0', 'clip0'], 'scope-a');
expect(scopeSvgReferenceValue('url(#paint0)', idMap)).toBe('url(#scope-a__paint0)');
expect(scopeSvgReferenceValue('url("#paint0")', idMap)).toBe('url("#scope-a__paint0")');
expect(scopeSvgReferenceValue('#clip0', idMap)).toBe('#scope-a__clip0');
expect(scopeSvgReferenceValue('url(#missing)', idMap)).toBe('url(#missing)');
});
it('scopes space-separated aria references', () => {
const idMap = createScopedSvgIdMap(['title-id', 'desc-id'], 'scope-a');
expect(scopeSpaceSeparatedSvgReferenceValue('title-id desc-id missing', idMap)).toBe(
'scope-a__title-id scope-a__desc-id missing'
);
});
it('scopes ids and references inside one svg instance only once', () => {
const gradient = new FakeSvgElement({ id: 'paint0' });
const title = new FakeSvgElement({ id: 'title-id' });
const rect = new FakeSvgElement({
fill: 'url(#paint0)',
'clip-path': 'url("#paint0")',
href: '#paint0',
'aria-labelledby': 'title-id missing'
});
const svg = new FakeSvgElement({ id: 'root' }, [gradient, title, rect]);
scopeSvgElementIds(svg as unknown as SVGSVGElement, 'scope-a');
expect(svg.getAttribute('id')).toBe('scope-a__root');
expect(gradient.getAttribute('id')).toBe('scope-a__paint0');
expect(title.getAttribute('id')).toBe('scope-a__title-id');
expect(rect.getAttribute('fill')).toBe('url(#scope-a__paint0)');
expect(rect.getAttribute('clip-path')).toBe('url("#scope-a__paint0")');
expect(rect.getAttribute('href')).toBe('#scope-a__paint0');
expect(rect.getAttribute('aria-labelledby')).toBe('scope-a__title-id missing');
scopeSvgElementIds(svg as unknown as SVGSVGElement, 'scope-a');
expect(gradient.getAttribute('id')).toBe('scope-a__paint0');
expect(rect.getAttribute('fill')).toBe('url(#scope-a__paint0)');
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment