Commit 7c54cd6b by Xianquan Committed by GitHub

fix(chat): align share chat history controls (#7195)

* fix(chat): align share chat history controls

* fix(chat): prevent action label selection

* chore: update pro submodule

* pro submodule
parent 4d90b720
......@@ -45,11 +45,12 @@ export type GetHistoriesResponseType = z.infer<typeof GetHistoriesResponseSchema
const GetHistoryStatusPropsSchema = {
chatIds: z.array(z.string().min(1)).min(1).max(200).describe('需要刷新状态的会话 ID 列表')
};
export const GetHistoryStatusBodyRawSchema = createOutLinkChatTargetInputSchema(
export const GetHistoryStatusBodyRawSchema = createOptionalOutLinkChatTargetInputSchema(
GetHistoryStatusPropsSchema
);
export const GetHistoryStatusBodySchema =
GetHistoryStatusBodyRawSchema.transform(transformChatTargetInput);
export const GetHistoryStatusBodySchema = GetHistoryStatusBodyRawSchema.transform(
transformOptionalChatTargetInput
);
export type GetHistoryStatusBodyType = z.infer<typeof GetHistoryStatusBodyRawSchema>;
export type GetHistoryStatusBodyRuntimeType = z.infer<typeof GetHistoryStatusBodySchema>;
......@@ -82,9 +83,10 @@ const UpdateHistoryPropsSchema = {
top: z.boolean().optional().describe('是否置顶')
};
export const UpdateHistoryBodyRawSchema =
createOutLinkChatTargetInputSchema(UpdateHistoryPropsSchema);
export const UpdateHistoryBodySchema =
UpdateHistoryBodyRawSchema.transform(transformChatTargetInput);
createOptionalOutLinkChatTargetInputSchema(UpdateHistoryPropsSchema);
export const UpdateHistoryBodySchema = UpdateHistoryBodyRawSchema.transform(
transformOptionalChatTargetInput
);
export type UpdateHistoryBodyType = z.infer<typeof UpdateHistoryBodyRawSchema>;
export type UpdateHistoryBodyRuntimeType = z.infer<typeof UpdateHistoryBodySchema>;
......
import React, { useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { type BoxProps } from '@chakra-ui/react';
import { useAudioPlay } from '@/web/common/utils/voice';
import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
......@@ -27,7 +27,7 @@ import { useCreation } from 'ahooks';
import type { ChatTypeEnum } from './constants';
import type { ChatQuickAppType } from '@fastgpt/global/core/chat/setting/type';
import { WorkflowRuntimeContextProvider } from '@/components/core/chat/ChatContainer/context/workflowRuntimeContext';
import { type ChatSourceTarget, toChatApiTarget } from '@/web/core/chat/utils';
import { getChatSourceKey, type ChatSourceTarget, toChatApiTarget } from '@/web/core/chat/utils';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
export type ChatProviderProps = {
......@@ -195,6 +195,10 @@ const Provider = ({
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords);
const resolvedChatTarget = useMemo(() => toChatApiTarget(sourceTarget), [sourceTarget]);
const audioScopeKey = useMemo(
() => `${getChatSourceKey(sourceTarget)}:${chatId}`,
[sourceTarget, chatId]
);
const resolvedAppId = useMemo(
() => (sourceTarget.sourceType === ChatSourceTypeEnum.app ? sourceTarget.sourceId : undefined),
[sourceTarget]
......@@ -217,6 +221,15 @@ const Provider = ({
...formatOutLinkAuth
});
const lastAudioScopeKeyRef = useRef(audioScopeKey);
useEffect(() => {
if (lastAudioScopeKeyRef.current === audioScopeKey) return;
lastAudioScopeKeyRef.current = audioScopeKey;
cancelAudio();
setAudioPlayingChatId(undefined);
}, [audioScopeKey, cancelAudio]);
const autoTTSResponse =
enableTTS &&
whisperConfig?.open &&
......
......@@ -94,6 +94,7 @@ const AIChatBubbleActions = ({
p={'4px'}
cursor={'pointer'}
color={'myGray.400'}
userSelect={'none'}
transition={footerActionTransition}
_hover={footerActionHoverStyle}
onClick={onOpenWholeModal}
......@@ -148,6 +149,7 @@ const AIChatBubbleActions = ({
p={'4px'}
cursor={'pointer'}
color={'myGray.400'}
userSelect={'none'}
_hover={{ color: 'primary.600' }}
onClick={onOpenSandboxModal}
>
......
......@@ -49,6 +49,9 @@ const AppChatWindow = () => {
const forbidLoadChatRef = useContextSelector(ChatContext, (v) => v.forbidLoadChat);
const onOpenSlider = useContextSelector(ChatContext, (v) => v.onOpenSlider);
const currentHistory = useContextSelector(ChatContext, (v) =>
v.histories.find((item) => item.chatId === chatId && item.appId === appId)
);
const isPlugin = useContextSelector(ChatItemContext, (v) => v.isPlugin);
const isShowCite = useContextSelector(ChatItemContext, (v) => v.isShowCite);
......@@ -57,6 +60,7 @@ const AppChatWindow = () => {
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const isCurrentChatReady = chatBoxData.appId === appId && chatBoxData.chatId === chatId;
const chatWindowTitle = getDisplayHistoryTitle({
customTitle: currentHistory?.customTitle,
title: isCurrentChatReady ? chatBoxData.title : undefined,
fallbackTitle: t('common:core.chat.New Chat')
});
......
......@@ -87,6 +87,9 @@ const HomeChatWindow = () => {
const forbidLoadChatRef = useContextSelector(ChatContext, (v) => v.forbidLoadChat);
const onOpenSlider = useContextSelector(ChatContext, (v) => v.onOpenSlider);
const currentHistory = useContextSelector(ChatContext, (v) =>
v.histories.find((item) => item.chatId === chatId && item.appId === appId)
);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
......@@ -105,6 +108,7 @@ const HomeChatWindow = () => {
const isCurrentChatReady = chatBoxData.appId === appId && chatBoxData.chatId === chatId;
const chatWindowTitle = getDisplayHistoryTitle({
customTitle: currentHistory?.customTitle,
title: isCurrentChatReady ? chatBoxData.title : undefined,
fallbackTitle: t('common:core.chat.New Chat')
});
......
......@@ -160,12 +160,14 @@ export const useSandboxStatus = ({
appId,
chatTarget,
chatId,
outLinkAuthData
outLinkAuthData,
enabled = true
}: {
appId?: string;
chatTarget?: ChatTargetInputType;
chatId: string;
outLinkAuthData?: OutLinkChatAuthProps;
enabled?: boolean;
}) => {
const { t } = useTranslation();
const [apiSandboxStatus, setApiSandboxStatus] = useState({
......@@ -174,10 +176,10 @@ export const useSandboxStatus = ({
exists: false
});
const sandboxTarget = useMemo(
() => resolveSandboxTarget({ appId, chatTarget }),
[appId, chatTarget?.appId, chatTarget?.skillId]
() => (enabled ? resolveSandboxTarget({ appId, chatTarget }) : undefined),
[appId, chatTarget?.appId, chatTarget?.skillId, enabled]
);
const sandboxTargetId = getSandboxTargetId(sandboxTarget);
const sandboxTargetId = sandboxTarget ? getSandboxTargetId(sandboxTarget) : '';
const chatRecords = useContextSelector(ChatRecordContext, (v) => {
return v.chatRecords;
......@@ -193,7 +195,7 @@ export const useSandboxStatus = ({
}, [chatRecords, isChatRecordsLoaded]);
useEffect(() => {
if (!sandboxTargetId || !chatId) return;
if (!sandboxTarget || !sandboxTargetId || !chatId) return;
let cancelled = false;
checkSandboxExist({ ...sandboxTarget, chatId, outLinkAuthData })
.then((result) => {
......
......@@ -60,7 +60,8 @@ const ToolMenu = ({
const { sandboxExists, setSandboxExists, SandboxEntryIcon } = useSandboxStatus({
appId: isShareAuthReady ? currentAppId : '',
chatId,
outLinkAuthData
outLinkAuthData,
enabled: isShareAuthReady
});
// UI Hook: 负责弹窗渲染
......
......@@ -12,6 +12,7 @@ import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import { getDisplayHistoryTitle } from '@/web/core/chat/context/historyTitleUtils';
const ChatSliderList = () => {
const { isPc } = useSystem();
......@@ -31,7 +32,6 @@ const ChatSliderList = () => {
const concatHistory = useMemo(() => {
const newChatTitle = t('common:core.chat.New Chat');
const getHistoryDisplayTitle = (title?: string) => title?.trim() || newChatTitle;
const scopedHistories = histories.filter((item) => item.appId === appId);
const formatHistories: {
......@@ -50,11 +50,14 @@ const ChatSliderList = () => {
chatBoxData.appId === item.appId;
const customTitle = item.customTitle?.trim() ? item.customTitle : undefined;
const realtimeTitle = chatBoxData.title?.trim() ? chatBoxData.title : undefined;
const title = (isActiveChat ? realtimeTitle : undefined) || customTitle || item.title;
return {
id: item.chatId,
title: getHistoryDisplayTitle(title),
title: getDisplayHistoryTitle({
customTitle,
title: (isActiveChat ? realtimeTitle : undefined) || item.title,
fallbackTitle: newChatTitle
}),
customTitle,
top: item.top,
updateTime: item.updateTime,
......@@ -76,7 +79,10 @@ const ChatSliderList = () => {
isTemporary?: boolean;
} = {
id: activeChatId,
title: getHistoryDisplayTitle(chatBoxData.chatId === activeChatId ? chatBoxData.title : ''),
title: getDisplayHistoryTitle({
title: chatBoxData.chatId === activeChatId ? chatBoxData.title : '',
fallbackTitle: newChatTitle
}),
updateTime: new Date(),
isTemporary: true,
chatGenerateStatus:
......
import type { NextApiResponse } from 'next';
import { UpdateHistoryBodySchema } from '@fastgpt/global/openapi/core/chat/history/api';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { authChatTargetCrud } from '@/service/support/permission/auth/chat';
import { NextAPI } from '@/service/middleware/entry';
import { type ApiRequestProps } from '@fastgpt/service/type/next';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { buildChatSourceQuery } from '@fastgpt/service/core/chat/source';
import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat';
import { buildChatHistoryMatch } from '@/service/core/chat/history';
/* update chat history: title, customTitle, top */
export async function handler(req: ApiRequestProps, res: NextApiResponse) {
const { sourceType, sourceId, chatId, title, customTitle, top } = parseApiInput({
const {
sourceType,
sourceId,
chatId,
title,
customTitle,
top,
shareId,
outLinkUid,
teamId,
teamToken
} = parseApiInput({
req,
bodySchema: UpdateHistoryBodySchema
}).body;
await authChatTargetCrud({
const match = await buildChatHistoryMatch({
req,
authToken: true,
authApiKey: true,
sourceType,
sourceId,
chatId,
shareId,
outLinkUid,
teamId,
teamToken,
per: WritePermissionVal
});
if (!match) return Promise.reject(ChatErrEnum.unAuthChat);
await MongoChat.updateOne(
{ ...buildChatSourceQuery({ sourceType, sourceId }), chatId },
{ ...match, chatId },
{
updateTime: new Date(),
...(title !== undefined && { title }),
......
......@@ -49,6 +49,7 @@ import Avatar from '@fastgpt/web/components/common/Avatar';
import { getAppChatSourceKey } from '@/web/core/chat/utils';
import { useAppChatGenerateStatusSync } from '@/pageComponents/chat/ChatWindow/useAppChatGenerateStatusSync';
import { postMarkChatRead } from '@/web/core/chat/history/api';
import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/SandboxEditor/hook';
const logger = getLogger(LogCategories.MODULE.CHAT.ITEM);
......@@ -115,7 +116,11 @@ const OutLink = (props: Props) => {
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const isChatRecordsLoaded = useContextSelector(ChatRecordContext, (v) => v.isChatRecordsLoaded);
const onChatGenerateStatusChange = useAppChatGenerateStatusSync();
const currentHistory = useContextSelector(ChatContext, (v) =>
v.histories.find((item) => item.chatId === chatId && item.appId === appId)
);
const chatWindowTitle = getDisplayHistoryTitle({
customTitle: currentHistory?.customTitle,
title: chatBoxData.title,
fallbackTitle: t('common:core.chat.New Chat')
});
......@@ -159,6 +164,19 @@ const OutLink = (props: Props) => {
);
const mobileHeaderAppName = props.appName || data?.app?.name || chatBoxData.app.name;
const mobileHeaderAppAvatar = props.appAvatar || data?.app?.avatar || chatBoxData.app.avatar;
const isShareAuthReady = !!outLinkAuthData.shareId && !!outLinkAuthData.outLinkUid;
const { SandboxEntryIcon } = useSandboxStatus({
appId: isShareAuthReady ? appId : '',
chatId,
outLinkAuthData,
enabled: isShareAuthReady
});
const { SandboxEditorModal, onOpenSandboxModal } = useSandboxEditor({
appId,
chatId,
outLinkAuthData,
enabled: isShareAuthReady
});
useEffect(() => {
if (initSign.current === false && data && isChatRecordsLoaded) {
......@@ -312,13 +330,12 @@ const OutLink = (props: Props) => {
{/* header */}
{showHead === '1' &&
(isPc ? (
!isPlugin && (
<ChatWindowHeader
title={chatWindowTitle}
history={chatRecords}
chatType={ChatTypeEnum.share}
/>
)
<ChatWindowHeader
title={chatWindowTitle}
history={chatRecords}
chatType={ChatTypeEnum.chat}
rightActions={<SandboxEntryIcon onOpen={onOpenSandboxModal} />}
/>
) : (
<Flex
h="48px"
......@@ -426,6 +443,7 @@ const OutLink = (props: Props) => {
/>
)}
</Box>
<SandboxEditorModal />
</Flex>
</Flex>
</PageContainer>
......
......@@ -13,6 +13,11 @@ import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
const splitMarker = 'SPLIT_MARKER';
const contentType = 'audio/mpeg';
const isAbortError = (error: unknown) =>
error instanceof DOMException
? error.name === 'AbortError'
: error instanceof Error && error.name === 'AbortError';
// 添加 MediaSource 支持检测函数
const isMediaSourceSupported = () => {
return typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported?.(contentType);
......@@ -129,25 +134,31 @@ export const useAudioPlay = (
if (!isMediaSourceSupported()) {
// 不支持 MediaSource 时,直接读取完整流并播放
return new Promise<Uint8Array>(async (resolve) => {
return new Promise<Uint8Array>(async (resolve, reject) => {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const fullBuffer = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
let offset = 0;
for (const chunk of chunks) {
fullBuffer.set(chunk, offset);
offset += chunk.length;
}
const fullBuffer = new Uint8Array(
chunks.reduce((acc, chunk) => acc + chunk.length, 0)
);
let offset = 0;
for (const chunk of chunks) {
fullBuffer.set(chunk, offset);
offset += chunk.length;
}
playAudioBuffer(fullBuffer);
resolve(fullBuffer);
playAudioBuffer(fullBuffer);
resolve(fullBuffer);
} catch (error) {
reject(error);
}
});
}
......@@ -219,6 +230,10 @@ export const useAudioPlay = (
resolve({});
}
} catch (error) {
if (isAbortError(error)) {
return resolve({});
}
toast({
status: 'error',
title: getErrText(error, t('common:core.chat.Audio Speech Error'))
......
......@@ -85,7 +85,31 @@ const ChatContextProvider = ({
const forbidLoadChat = useRef(false);
const { chatId, setChatId, outLinkAuthData } = useChatStore();
const historyAppId = String(params.appId ?? '');
const historyAppId = typeof params.appId === 'string' ? params.appId : '';
const historySkillId = typeof params.skillId === 'string' ? params.skillId : '';
const historyTarget = useMemo(
() => ({
...(historyAppId ? { appId: historyAppId } : {}),
...(historySkillId ? { skillId: historySkillId } : {})
}),
[historyAppId, historySkillId]
);
const historyAuthData = useMemo(
() => ({
...outLinkAuthData,
...(typeof params.shareId === 'string' ? { shareId: params.shareId } : {}),
...(typeof params.outLinkUid === 'string' ? { outLinkUid: params.outLinkUid } : {}),
...(typeof params.teamId === 'string' ? { teamId: params.teamId } : {}),
...(typeof params.teamToken === 'string' ? { teamToken: params.teamToken } : {})
}),
[
outLinkAuthData,
params.outLinkUid,
params.shareId,
params.teamId,
params.teamToken
]
);
const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: openSlider } = useDisclosure();
const openSliderTimerRef = useRef<ReturnType<typeof setTimeout>>();
......@@ -167,9 +191,9 @@ const ChatContextProvider = ({
const { runAsync: onUpdateHistory } = useRequest(
(data: UpdateHistoryParams) =>
putChatHistory({
appId: historyAppId,
...historyTarget,
...data,
...outLinkAuthData
...historyAuthData
}),
{
onBefore(params) {
......@@ -180,7 +204,7 @@ const ChatContextProvider = ({
if (history.chatId === chatId) {
return {
...history,
customTitle: customTitle || history.customTitle,
customTitle: customTitle !== undefined ? customTitle : history.customTitle,
top: top !== undefined ? top : history.top
};
}
......@@ -192,7 +216,7 @@ const ChatContextProvider = ({
: updatedHistories;
});
},
refreshDeps: [outLinkAuthData, historyAppId],
refreshDeps: [historyAuthData, historyTarget],
errorToast: undefined
}
);
......@@ -200,9 +224,9 @@ const ChatContextProvider = ({
const { runAsync: onDelHistory, loading: isDeletingHistory } = useRequest(
(chatId: string) =>
delChatHistoryById({
...(historyAppId ? { appId: historyAppId } : {}),
...historyTarget,
chatId,
...outLinkAuthData
...historyAuthData
}),
{
onSuccess(data, params) {
......@@ -214,18 +238,18 @@ const ChatContextProvider = ({
setHistoriesTotal((total) => Math.max(total - 1, 0));
}
},
refreshDeps: [outLinkAuthData, historyAppId]
refreshDeps: [historyAuthData, historyTarget]
}
);
const { runAsync: onClearHistories, loading: isClearingHistory } = useRequest(
() =>
delClearChatHistories({
...(historyAppId ? { appId: historyAppId } : {}),
...outLinkAuthData
...historyTarget,
...historyAuthData
}),
{
refreshDeps: [outLinkAuthData, historyAppId],
refreshDeps: [historyAuthData, historyTarget],
onSuccess() {
setHistories([]);
setHistoriesTotal(0);
......@@ -305,9 +329,9 @@ const ChatContextProvider = ({
const poll = () => {
const chatIds = historiesRef.current.map((h) => h.chatId);
getChatHistoryStatus({
...(historyAppId ? { appId: historyAppId } : {}),
...historyTarget,
chatIds,
...outLinkAuthData
...historyAuthData
})
.then((res) => {
const map = new Map(res.list.map((i) => [i.chatId, i]));
......@@ -356,7 +380,7 @@ const ChatContextProvider = ({
window.clearInterval(timer);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [historyAppId, historyChatIdsKey, hasGeneratingInSidebar, outLinkAuthData, setHistories]);
}, [historyTarget, historyChatIdsKey, hasGeneratingInSidebar, historyAuthData, setHistories]);
const isLoading = isDeletingHistory || isClearingHistory || isPaginationLoading;
......
......@@ -3,14 +3,17 @@ import type { ChatHistoryItemType } from '@fastgpt/global/core/chat/type';
import i18next from 'i18next';
export const getDisplayHistoryTitle = ({
customTitle,
title,
fallbackTitle = i18next.t('common:core.chat.New Chat')
}: {
customTitle?: string;
title?: string;
fallbackTitle?: string;
}) => {
const normalizedCustomTitle = customTitle?.trim();
const normalizedTitle = title?.trim();
return normalizedTitle || fallbackTitle;
return normalizedCustomTitle || normalizedTitle || fallbackTitle;
};
export const normalizeHistoryTitle = (history: ChatHistoryItemType) => ({
......
......@@ -5,12 +5,14 @@ import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { MongoOutLink } from '@fastgpt/service/support/outLink/schema';
import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest';
import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema';
import { AppReadChatLogPerVal } from '@fastgpt/global/support/permission/app/constant';
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
describe('updateHistory api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>;
......@@ -120,6 +122,51 @@ describe('updateHistory api test', () => {
expect(updatedChat?.top).toBe(true);
});
it('should update top status for share history without appId', async () => {
const shareId = `share-update-history-${getNanoid()}`;
const outLinkUid = `share-user-${getNanoid()}`;
await MongoOutLink.create({
shareId,
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
type: PublishChannelEnum.share,
name: 'Share Link'
});
await MongoChat.updateOne(
{ appId, chatId },
{
$set: {
shareId,
outLinkUid,
source: ChatSourceEnum.share
}
}
);
const res = await Call<UpdateHistoryBodyType, {}>(handler, {
body: {
shareId,
outLinkUid,
chatId,
top: true
}
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
const updatedChat = await MongoChat.findOne({
appId,
chatId,
shareId,
outLinkUid
});
expect(updatedChat?.top).toBe(true);
});
it('should update multiple fields at once', async () => {
const newTitle = 'New Title';
const customTitle = 'New Custom Title';
......
......@@ -11,11 +11,6 @@
"cache": false,
"persistent": true
},
"dev:pro": {
"with": ["@fastgpt/admin#dev"],
"cache": false,
"persistent": true
},
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**", "worker/**"]
......
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