fix: parent datasetId type;fix: v1 completions; (#6775)

* fix: parent datasetId type

* doc

* fix(chat): keep stream resume out of v1 completions (#6774)

* fix(chat): avoid duplicate v1 completion history items

* fix(chat): restore v1 completion persistence flow

* fix(chat): keep stream resume out of v1 completions

* fix(chat): revert pushChatRecords append flow

* Mobile UI (#6776)

* doc

* perf: review

* fix: review

---------

Co-authored-by: Ryo <whoeverimf5@gmail.com>
Co-authored-by: YeYuheng <57035043+YYH211@users.noreply.github.com>
This commit is contained in:
Archer
2026-04-20 17:45:22 +08:00
committed by GitHub
parent d9b096844d
commit 181f743901
10 changed files with 253 additions and 256 deletions
@@ -0,0 +1,21 @@
---
title: 'V4.14.12'
description: 'FastGPT V4.14.12 更新说明'
---
## 🐛 修复
1. 知识库三级目录 path 接口报 zod 校验出错。
2. v1/completions 接口 dataId 异常,导致 api 调用时候,对话日志里无法获取到运行详情。
## 🚀 新增内容
1. 响应值允许自定义 HttpStatus 状态码。
2. Agent 调度器支持 PI Agent 模式(beta功能)。
## ⚙️ 优化
1. skill 接口错误处理。
+1
View File
@@ -115,6 +115,7 @@ description: FastGPT 文档目录
- [/docs/self-host/upgrading/4-14/4141](/docs/self-host/upgrading/4-14/4141)
- [/docs/self-host/upgrading/4-14/41410](/docs/self-host/upgrading/4-14/41410)
- [/docs/self-host/upgrading/4-14/41411](/docs/self-host/upgrading/4-14/41411)
- [/docs/self-host/upgrading/4-14/41412](/docs/self-host/upgrading/4-14/41412)
- [/docs/self-host/upgrading/4-14/4142](/docs/self-host/upgrading/4-14/4142)
- [/docs/self-host/upgrading/4-14/4143](/docs/self-host/upgrading/4-14/4143)
- [/docs/self-host/upgrading/4-14/4144](/docs/self-host/upgrading/4-14/4144)
+3 -2
View File
@@ -225,6 +225,7 @@
"document/content/docs/self-host/upgrading/4-14/41410.en.mdx": "2026-03-31T23:15:29+08:00",
"document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-04-18T20:47:39+08:00",
"document/content/docs/self-host/upgrading/4-14/41411.mdx": "2026-04-18T20:47:39+08:00",
"document/content/docs/self-host/upgrading/4-14/41412.mdx": "2026-04-20T15:54:50+08:00",
"document/content/docs/self-host/upgrading/4-14/4142.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4142.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4143.en.mdx": "2026-03-03T17:39:47+08:00",
@@ -384,9 +385,9 @@
"document/content/docs/self-host/upgrading/outdated/499.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/outdated/499.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/upgrade-intruction.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/upgrade-intruction.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/upgrade-intruction.mdx": "2026-04-20T13:51:34+08:00",
"document/content/docs/toc.en.mdx": "2026-04-17T23:28:43+08:00",
"document/content/docs/toc.mdx": "2026-04-17T23:28:43+08:00",
"document/content/docs/toc.mdx": "2026-04-20T15:24:07+08:00",
"document/content/docs/use-cases/app-cases/dalle3.en.mdx": "2026-02-26T22:14:30+08:00",
"document/content/docs/use-cases/app-cases/dalle3.mdx": "2025-07-23T21:35:03+08:00",
"document/content/docs/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-02-26T22:14:30+08:00",
+6 -1
View File
@@ -1,6 +1,11 @@
import z from 'zod';
export const ParentIdSchema = z.string().nullish();
export const ParentIdSchema = z
.preprocess(
(value) => (value !== null && typeof value === 'object' ? String(value) : value),
z.string().regex(/^([0-9a-fA-F]{24})?$/)
)
.nullish();
export type ParentIdType = string | null | undefined;
export const GetPathPropsSchema = z.object({
+19 -150
View File
@@ -387,72 +387,6 @@ export const prepareChatRound = async (params: PrepareChatRoundParams) => {
});
};
export const ensurePendingChatRoundItems = async (params: EnsurePendingChatRoundParams) => {
const { chatId, appId, teamId, tmbId, responseChatItemId } = params;
if (!chatId || chatId === 'NO_RECORD_HISTORIES') return;
const humanDataId = params.userContent.dataId ?? responseChatItemId;
const existingAi = await MongoChatItem.findOne({
appId,
chatId,
dataId: responseChatItemId,
obj: ChatRoleEnum.AI
})
.select('_id')
.lean();
if (existingAi) return;
const userPayload: UserChatItemType & { dataId: string; obj: typeof ChatRoleEnum.Human } = {
...params.userContent,
dataId: humanDataId,
obj: ChatRoleEnum.Human
};
userPayload.value?.forEach((item) => {
if ('file' in item && item.file?.key) {
item.file.url = '';
}
});
const aiPlaceholder: AIChatItemType & { dataId: string } = {
dataId: responseChatItemId,
obj: ChatRoleEnum.AI,
value: []
};
await mongoSessionRun(async (session) => {
const upsertOpts = { session, upsert: true };
await MongoChatItem.updateOne(
{ appId, chatId, dataId: humanDataId, obj: ChatRoleEnum.Human },
{
$setOnInsert: {
teamId,
tmbId,
chatId,
appId,
...userPayload
}
},
upsertOpts
);
await MongoChatItem.updateOne(
{ appId, chatId, dataId: responseChatItemId, obj: ChatRoleEnum.AI },
{
$setOnInsert: {
teamId,
tmbId,
chatId,
appId,
...aiPlaceholder
}
},
upsertOpts
);
});
};
export const finalizeChatRound = async (props: Props) => {
beforeProcess(props);
@@ -771,96 +705,30 @@ export const pushChatRecords = async (props: Props) => {
errorMsg
});
const processedContent = [userContent, aiResponse];
const humanRoundDataId = (processedContent[0] as { dataId?: string }).dataId as string;
const aiRoundDataId = (processedContent[1] as { dataId?: string }).dataId as string;
await mongoSessionRun(async (session) => {
const humanExisting = await MongoChatItem.findOne({
appId,
chatId,
dataId: humanRoundDataId,
obj: ChatRoleEnum.Human
}).session(session);
const aiExisting = await MongoChatItem.findOne({
appId,
chatId,
dataId: aiRoundDataId,
obj: ChatRoleEnum.AI
}).session(session);
const [{ _id: chatItemIdHuman }, { _id: chatItemIdAi, dataId }] = await MongoChatItem.create(
processedContent.map((item) => ({
chatId,
teamId,
tmbId,
appId,
...item
})),
{ session, ordered: true, ...writePrimary }
);
let chatItemIdHuman: unknown;
let chatItemIdAi: unknown;
let responseDataId = aiRoundDataId;
if (humanExisting && aiExisting) {
await MongoChatItem.updateOne(
{ _id: humanExisting._id },
{
$set: {
...(processedContent[0] as Record<string, unknown>),
obj: ChatRoleEnum.Human
}
},
{ session }
);
await MongoChatItem.updateOne(
{ _id: aiExisting._id },
{
$set: {
...(processedContent[1] as Record<string, unknown>),
obj: ChatRoleEnum.AI
}
},
{ session }
);
await MongoChatItemResponse.deleteMany(
{ appId, chatId, chatItemDataId: aiRoundDataId },
{ session }
);
if (nodeResponses?.length) {
await MongoChatItemResponse.create(
nodeResponses.map((item) => ({
teamId,
appId,
chatId,
chatItemDataId: aiRoundDataId,
data: item
})),
{ session, ordered: true }
);
}
chatItemIdHuman = humanExisting._id;
chatItemIdAi = aiExisting._id;
} else {
const [humanCreated, aiCreated] = await MongoChatItem.create(
processedContent.map((item) => ({
chatId,
if (nodeResponses) {
await MongoChatItemResponse.create(
nodeResponses.map((item) => ({
teamId,
tmbId,
appId,
...item
chatId,
chatItemDataId: dataId,
data: item
})),
{ session, ordered: true }
{ session, ordered: true, ...writePrimary }
);
chatItemIdHuman = humanCreated._id;
chatItemIdAi = aiCreated._id;
responseDataId = aiCreated.dataId;
if (nodeResponses) {
await MongoChatItemResponse.create(
nodeResponses.map((item) => ({
teamId,
appId,
chatId,
chatItemDataId: responseDataId,
data: item
})),
{ session, ordered: true }
);
}
}
await MongoChat.updateOne(
@@ -894,7 +762,8 @@ export const pushChatRecords = async (props: Props) => {
},
{
session,
upsert: true
upsert: true,
...writePrimary
}
);
@@ -217,34 +217,50 @@ const ModelTable = ({ permissionConfig = false }: { permissionConfig?: boolean }
});
return (
<Flex flexDirection={'column'} h={'100%'}>
<Flex>
<HStack flexShrink={0}>
<Box fontSize={'sm'} color={'myGray.900'}>
<Flex flexDirection={'column'} h={'100%'} minW={0}>
<Flex flexDirection={['column', 'row']} gap={[3, 0]} alignItems={['stretch', 'center']}>
<Flex flexShrink={0} w={['100%', 'auto']} alignItems={'center'} gap={2}>
<Box
w={['84px', 'auto']}
flexShrink={0}
fontSize={'sm'}
color={'myGray.900'}
textAlign={'left'}
>
{t('common:model.provider')}
</Box>
<MySelect
w={'200px'}
bg={'myGray.50'}
value={provider}
onChange={setProvider}
list={filterProviderList}
/>
</HStack>
<HStack flexShrink={0} ml={6}>
<Box fontSize={'sm'} color={'myGray.900'}>
<Box flex={1} minW={0} w={['100%', '200px']}>
<MySelect
w={'100%'}
bg={'myGray.50'}
value={provider}
onChange={setProvider}
list={filterProviderList}
/>
</Box>
</Flex>
<Flex flexShrink={0} ml={[0, 6]} w={['100%', 'auto']} alignItems={'center'} gap={2}>
<Box
w={['84px', 'auto']}
flexShrink={0}
fontSize={'sm'}
color={'myGray.900'}
textAlign={'left'}
>
{t('common:model.model_type')}
</Box>
<MySelect
w={'150px'}
bg={'myGray.50'}
value={modelType}
onChange={setModelType}
list={selectModelTypeList.current}
/>
</HStack>
<Box flex={1} />
<Box flex={'0 0 250px'}>
<Box flex={1} minW={0} w={['100%', '150px']}>
<MySelect
w={'100%'}
bg={'myGray.50'}
value={modelType}
onChange={setModelType}
list={selectModelTypeList.current}
/>
</Box>
</Flex>
<Box flex={1} display={['none', 'block']} />
<Box w={['100%', '250px']} flex={['none', '0 0 250px']}>
<SearchInput
bg={'myGray.50'}
value={search}
@@ -253,7 +269,15 @@ const ModelTable = ({ permissionConfig = false }: { permissionConfig?: boolean }
/>
</Box>
</Flex>
<TableContainer mt={5} flex={'1 0 0'} h={0} overflowY={'auto'}>
<TableContainer
mt={5}
flex={'1 0 0'}
h={0}
w={'100%'}
maxW={'100%'}
overflowY={'auto'}
overflowX={'auto'}
>
<Table>
<Thead>
<Tr color={'myGray.600'}>
@@ -20,7 +20,15 @@ const Points = () => {
<Link href="https://tiktokenizer.vercel.app/" target="_blank" mb={['30px', 10]}>
{t('common:support.wallet.subscription.token_compute')}
</Link>
<Box p={5} w={'100%'} h={'666px'} bg={'white'} borderRadius={'lg'} boxShadow={'md'}>
<Box
p={[3, 5]}
w={'100%'}
h={'666px'}
bg={'white'}
borderRadius={'lg'}
boxShadow={'md'}
overflow={'hidden'}
>
<ModelTable />
</Box>
</Flex>
+30 -25
View File
@@ -271,36 +271,41 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
</MyTooltip>
</Flex>
) : (
<Flex
flexDirection={'column'}
alignItems={'center'}
cursor={'pointer'}
onClick={handleFileSelectorOpen}
>
<MyTooltip label={t('account_info:choose_avatar')}>
<Box
w={['44px', '54px']}
h={['44px', '54px']}
borderRadius={'50%'}
border={theme.borders.base}
overflow={'hidden'}
p={'2px'}
boxShadow={'0 0 5px rgba(0,0,0,0.1)'}
mb={2}
>
<Avatar src={userInfo?.avatar} borderRadius={'50%'} w={'100%'} h={'100%'} />
</Box>
</MyTooltip>
<Flex mt={4} alignItems={'center'}>
<Box {...labelStyles}>{t('account_info:avatar')}&nbsp;</Box>
<Flex
flex={'1 0 0'}
w={0}
alignItems={'center'}
gap={2}
cursor={'pointer'}
onClick={handleFileSelectorOpen}
>
<MyTooltip label={t('account_info:choose_avatar')}>
<Box
w={'40px'}
h={'40px'}
borderRadius={'50%'}
border={'1px solid'}
borderColor={'borderColor.base'}
overflow={'hidden'}
p={'2px'}
bg={'white'}
>
<Avatar src={userInfo?.avatar} borderRadius={'50%'} w={'100%'} h={'100%'} />
</Box>
</MyTooltip>
<Flex alignItems={'center'} fontSize={'sm'} color={'myGray.600'}>
<MyIcon mr={1} name={'edit'} w={'14px'} />
{t('account_info:change')}
<Flex alignItems={'center'} fontSize={'sm'} color={'myGray.600'}>
<MyIcon mr={1} name={'edit'} w={'14px'} />
{t('account_info:change')}
</Flex>
</Flex>
</Flex>
)}
{feConfigs?.isPlus && (
<Flex mt={[0, 4]} alignItems={'center'}>
<Flex mt={[4, 4]} alignItems={'center'}>
<Box {...labelStyles}>{t('account_info:member_name')}&nbsp;</Box>
<Input
flex={'1 0 0'}
@@ -308,7 +313,7 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
defaultValue={userInfo?.team?.memberName || 'Member'}
title={t('account_info:click_modify_nickname')}
borderColor={'transparent'}
transform={'translateX(-11px)'}
transform={['none', 'translateX(-11px)']}
maxLength={100}
onBlur={async (e) => {
const val = e.target.value;
@@ -18,7 +18,6 @@ import { GPTMessages2Chats, chatValue2RuntimePrompt } from '@fastgpt/global/core
import { getChatItems } from '@fastgpt/service/core/chat/controller';
import {
type Props as SaveChatProps,
ensurePendingChatRoundItems,
pushChatRecords,
updateInteractiveChat
} from '@fastgpt/service/core/chat/saveChat';
@@ -65,11 +64,6 @@ import { formatTime2YMDHM } from '@fastgpt/global/common/string/time';
import { LimitTypeEnum, teamFrequencyLimit } from '@fastgpt/service/common/api/frequencyLimit';
import { getIpFromRequest } from '@fastgpt/service/common/geo';
import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils';
import {
ensureGenerateChat,
updateChatGenerateStatus
} from '@fastgpt/service/core/chat/chatGenerateStatus';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
const logger = getLogger(LogCategories.MODULE.CHAT.ITEM);
@@ -96,8 +90,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} = CompletionsPropsSchema.parse(req.body);
const startTime = Date.now();
let runningChatId: string | undefined;
let runningAppId: string | undefined;
const originIp = getIpFromRequest(req);
@@ -263,36 +255,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return ChatSourceEnum.online;
})();
runningChatId = saveChatId;
runningAppId = String(app._id);
await ensureGenerateChat({
appId: runningAppId,
chatId: runningChatId,
teamId,
tmbId: tmbId,
source,
sourceName: sourceName || '',
shareId,
outLinkUid: outLinkUserId
});
// 流式 + 站内 online:工作流 dispatch 走 v2 管道;与 HTTP 路径是 /v1 还是 /v2 无关
const shouldUseWorkflowStreamV2 = stream && source === ChatSourceEnum.online;
const workflowApiVersion = shouldUseWorkflowStreamV2 ? 'v2' : 'v1';
// OpenAI 兼容 /v1/chat/completions 不镜像 SSE 到 Redis;断线续传由 /api/v2/chat/completions + /api/core/chat/resume 承担
if (!interactive) {
await ensurePendingChatRoundItems({
chatId: saveChatId,
appId: runningAppId,
teamId,
tmbId: String(tmbId),
userContent: userQuestion,
responseChatItemId
});
}
const workflowResponseWrite = getWorkflowResponseWrite({
res,
detail,
@@ -313,7 +275,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} = await (async () => {
if (app.version === 'v2') {
return dispatchWorkFlow({
apiVersion: workflowApiVersion,
apiVersion: 'v1',
res,
lang: getLocale(req),
requestOrigin: req.headers.origin,
@@ -390,12 +352,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
await pushChatRecords(params);
}
await updateChatGenerateStatus({
appId: runningAppId,
chatId: runningChatId,
status: ChatGenerateStatusEnum.done
});
const isOwnerUse = !shareId && !spaceTeamId && String(tmbId) === String(app.tmbId);
if (isOwnerUse && source === ChatSourceEnum.online) {
await recordAppUsage({
@@ -540,13 +496,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
});
}
} catch (err) {
if (runningAppId && runningChatId) {
await updateChatGenerateStatus({
appId: runningAppId,
chatId: runningChatId,
status: ChatGenerateStatusEnum.error
});
}
if (stream) {
sseErrRes(res, err);
res.end();
@@ -1,6 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getParents } from '@/pages/api/core/dataset/paths';
import { MongoDataset } from '@fastgpt/service/core/dataset/schema';
import { GetDatasetPathsResponseSchema } from '@fastgpt/global/openapi/core/dataset/api';
import { ParentIdSchema } from '@fastgpt/global/common/parentFolder/type';
class FakeObjectId {
constructor(private readonly hex: string) {}
toString() {
return this.hex;
}
}
vi.mock('@fastgpt/service/core/dataset/schema', () => ({
MongoDataset: {
@@ -62,6 +71,34 @@ describe('getParents', () => {
]);
});
it('should coerce ObjectId-like values through the response schema', async () => {
const childHex = '69e5ca9ce4f63f23d53848da';
const parentHex = '69e5ca9ce4f63f23d53848db';
const childObjectId = new FakeObjectId(childHex);
const parentObjectId = new FakeObjectId(parentHex);
vi.mocked(MongoDataset.findById)
.mockResolvedValueOnce({
name: 'Child',
parentId: parentObjectId
})
.mockResolvedValueOnce({
name: 'Parent',
parentId: null
});
const result = await getParents(childObjectId as unknown as string);
const parsed = GetDatasetPathsResponseSchema.parse(result);
expect(parsed).toEqual([
{ parentId: parentHex, parentName: 'Parent' },
{ parentId: childHex, parentName: 'Child' }
]);
for (const item of parsed) {
expect(typeof item.parentId).toBe('string');
}
});
it('should handle circular references gracefully', async () => {
vi.mocked(MongoDataset.findById)
.mockResolvedValueOnce({
@@ -81,3 +118,80 @@ describe('getParents', () => {
]);
});
});
describe('ParentIdSchema', () => {
const validHex = '69e5ca9ce4f63f23d53848da';
describe('accepts', () => {
it('24-char lowercase hex string (ObjectId shape)', () => {
expect(ParentIdSchema.parse(validHex)).toBe(validHex);
});
it('24-char uppercase / mixed-case hex string', () => {
const upper = 'ABCDEF0123456789ABCDEF01';
const mixed = 'AbCdEf0123456789abcdef01';
expect(ParentIdSchema.parse(upper)).toBe(upper);
expect(ParentIdSchema.parse(mixed)).toBe(mixed);
});
it('empty string (root sentinel)', () => {
expect(ParentIdSchema.parse('')).toBe('');
});
it('null', () => {
expect(ParentIdSchema.parse(null)).toBeNull();
});
it('undefined', () => {
expect(ParentIdSchema.parse(undefined)).toBeUndefined();
});
it('ObjectId-like object with toString', () => {
const oid = new FakeObjectId(validHex);
expect(ParentIdSchema.parse(oid)).toBe(validHex);
});
});
describe('rejects', () => {
it('arbitrary non-hex string', () => {
expect(() => ParentIdSchema.parse('parent1-id')).toThrow();
expect(() => ParentIdSchema.parse('not-an-id')).toThrow();
});
it('hex string with wrong length', () => {
// 23 chars
expect(() => ParentIdSchema.parse('69e5ca9ce4f63f23d53848d')).toThrow();
// 25 chars
expect(() => ParentIdSchema.parse('69e5ca9ce4f63f23d53848daa')).toThrow();
});
it('24-char string containing non-hex character', () => {
expect(() => ParentIdSchema.parse('69e5ca9ce4f63f23d53848dz')).toThrow();
});
it('number', () => {
expect(() => ParentIdSchema.parse(123)).toThrow();
});
it('boolean', () => {
expect(() => ParentIdSchema.parse(true)).toThrow();
expect(() => ParentIdSchema.parse(false)).toThrow();
});
it('plain object whose toString produces non-hex', () => {
// String({}) -> "[object Object]" -> regex fails
expect(() => ParentIdSchema.parse({})).toThrow();
expect(() => ParentIdSchema.parse({ foo: 'bar' })).toThrow();
});
it('array', () => {
// String([1, 2]) -> "1,2" -> regex fails
expect(() => ParentIdSchema.parse([1, 2])).toThrow();
});
it('ObjectId-like object whose toString produces invalid hex', () => {
const bad = new FakeObjectId('not-hex');
expect(() => ParentIdSchema.parse(bad)).toThrow();
});
});
});