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
@@ -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();
});
});
});